From fe770504732c1ad9695174092d2898cc717ccf95 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Sun, 21 Jun 2026 22:20:34 +0800 Subject: [PATCH 001/244] docs: design plugin platform kernel for 0.62 --- ...-hub-0-62-plugin-platform-kernel-design.md | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel-design.md diff --git a/docs/superpowers/specs/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel-design.md b/docs/superpowers/specs/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel-design.md new file mode 100644 index 00000000..5bc2a7fe --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel-design.md @@ -0,0 +1,336 @@ +# aio-coding-hub 0.62.0 插件平台内核设计 + +日期:2026-06-21 + +## 目标 + +0.62.0 是插件系统的平台内核重构版本。目标不是开放更多外部插件 API,而是在插件生态仍然早期时,把宿主内部的 contract、hook、runtime、provider adapter 边界整理清楚,降低后续扩展的技术债和维护成本。 + +本版本必须保持现有功能一致: + +- `plugin.json` v1 外部结构不破坏。 +- 现有 active hooks 不重命名、不改变触发语义。 +- `declarativeRules`、`official.privacy-filter`、`@aio-coding-hub/plugin-sdk`、`create-aio-plugin validate/replay/pack` 保持兼容。 +- 不新增公开 provider 插件 API。 +- 不开放第三方 JavaScript/TypeScript 或 WebView 插件执行。 + +## 背景判断 + +项目是 Tauri 2 GUI 桌面应用,目标平台是 Linux、macOS、Windows 的可执行程序,不是 H5 应用。因此插件执行边界应在 Rust host、gateway、provider adapter、文件安装、IPC 和审计层;React 前端负责管理、配置、授权和可视化,不作为插件执行环境。 + +当前 Plugin API v1 的外部设计没有明显硬伤。它保持了较小的 active surface:gateway request/response/stream/log hooks、权限裁剪、声明式规则、官方 privacy filter 和 SDK/脚手架。问题主要在内部实现: + +- contract 信息分散在 JSON、Rust、TypeScript、文档和脚手架中。 +- hook 语义散落在 enum、validation、context trimming、permission enforcement、pipeline 和真实 gateway 调用点中。 +- runtime dispatch、缓存、policy 和错误分类还没有形成清晰的平台抽象。 +- provider 特例逻辑分散在 middleware、failover prepare、attempt、response 等路径中,未来扩展 provider 能力会继续推高 gateway hot path 复杂度。 + +因此 0.62.0 采用内部平台化重构,不引入 Plugin API v2。只有后续证明 v1 外部 API 阻塞平台化时,才单独进入 v2 RFC。 + +## 参考实践 + +设计参考成熟插件系统的共同模式: + +- VS Code 使用 manifest 声明 contribution points、activation events,并通过 extension host 隔离执行。 +- Chrome Extensions 使用 manifest permissions、service worker 生命周期和 declarative APIs 控制能力边界。 +- Tauri 2 通过 Rust 插件、commands 和权限把桌面能力暴露给 WebView。 +- Kong Gateway 使用明确的 gateway phase、priority、schema 和 handler 作为插件扩展点。 +- Backstage 和 JetBrains 平台强调 extension points,而不是让插件任意修改宿主内部。 + +这些实践共同支持本版本的方向:稳定声明式 contract、明确 extension points、权限与运行时隔离、host core 小而可信。 + +## 总体方案 + +采用分层平台化方案,按依赖顺序推进: + +1. Contract Layer:`plugin-api-v1-contract.json` 成为唯一事实源。 +2. Hook Registry Layer:统一 hook 阶段、context、permission、mutation、failure policy 和审计规则。 +3. Runtime Layer:统一 runtime dispatch、policy、cache lifecycle、错误分类和性能预算。 +4. Provider Adapter Layer:收敛 CX2CC、Gemini OAuth、Codex/Claude 等 provider 特例到内部 adapter/capability 层。 + +外部 Plugin API v1 保持不变。新增能力只在内部沉淀,不在 0.62.0 暴露给第三方插件作者。 + +```mermaid +flowchart TD + Contract["plugin-api-v1-contract.json"] --> RustTs["Rust/TypeScript contract checks"] + RustTs --> HookRegistry["Hook Registry"] + HookRegistry --> GatewayHooks["Gateway hook call sites"] + HookRegistry --> RuntimeManager["Runtime Manager"] + RuntimeManager --> Declarative["declarativeRules"] + RuntimeManager --> Official["official.privacy-filter"] + RuntimeManager --> WasmPolicy["policy-gated WASM"] + GatewayHooks --> ProviderAdapters["Provider Adapter Layer"] + ProviderAdapters --> Gateway["Gateway failover/provider hot path"] +``` + +## Contract Layer + +### 当前短板 + +Hook、permission、runtime、mutation 和 config schema 信息散在多处。现有检查脚本能发现部分字符串缺失,但不能完整证明 Rust validation、TypeScript SDK、docs、scaffold、replay 与 canonical contract 的结构一致。 + +### 设计 + +继续使用 `docs/plugins/plugin-api-v1-contract.json` 作为 canonical contract,并扩展其结构化表达: + +- hook id、类别、active/reserved 状态、阶段说明。 +- context fields 与 read permissions 的对应关系。 +- mutation fields 与 write permissions 的对应关系。 +- 默认 timeout、failure policy、reserved header policy。 +- runtime 分类:community、policy-gated、official-only。 +- config schema 支持类型。 +- plugin API compatibility 与 SemVer 规则。 + +0.62.0 先以结构化强校验为主,不要求一次性把 Rust/TS 全部改为生成代码。这样可以降低一次性 diff 风险,同时保证新增或修改 contract 时,未同步的 Rust、SDK、docs 或 scaffold 会明确失败。 + +### 验收 + +- `pnpm check:plugin-api-contract` 能指出具体 drift 类型:SDK 缺 union、Rust validation 缺 hook、docs 缺描述、scaffold/replay 不支持。 +- 历史字段如 `contextPatch` 不能重新进入 active contract。 +- 不改变 `PluginManifest` 和 `PluginHookResult` 的对外 shape。 +- contract check 覆盖 active hooks、reserved hooks、active permissions、reserved permissions、runtime categories、mutation fields、failure policy、timeout。 + +## Hook Registry Layer + +### 当前短板 + +Hook 语义分散在多处:hook 名称、active/reserved 判断、context trimming、mutation enforcement、排序、timeout、failure policy、audit 和真实调用点。新增 hook 需要跨多个文件同步,容易造成 manifest 校验、SDK 类型、运行时触发、权限 enforcement 之间的语义漂移。 + +### 设计 + +新增内部 `HookRegistry` 和 `HookDescriptor`,由 contract metadata 驱动。每个 descriptor 描述: + +- `id`:例如 `gateway.request.afterBodyRead`。 +- `kind`:request、response、stream、log。 +- `phase`:body-read 后、before-send、response-after、stream-chunk 等。 +- `status`:active 或 reserved。 +- `defaultFailurePolicy` 和 `timeoutBudget`。 +- `allowedReadPermissions`。 +- `allowedMutationFields`。 +- `requiredWritePermissions`。 +- `reservedHeaderPolicy`。 +- `auditPolicy`。 + +模块边界建议: + +```text +src-tauri/src/gateway/plugins/ + contract.rs + registry.rs + context.rs + mutation.rs + pipeline.rs +``` + +`context.rs` 保留 context model 和 builders;`mutation.rs` 负责 result normalization、header/body/stream/log mutation 应用和权限 enforcement;`pipeline.rs` 只负责排序、timeout、circuit、audit 和 executor 调用。 + +### 行为保持 + +- `gateway.request.afterBodyRead` 仍在 body reader 后执行。 +- `gateway.request.beforeSend` 仍在单次 provider attempt 发送前执行。 +- `gateway.response.chunk` 仍是 chunk 级流式处理,不缓冲完整 stream。 +- `gateway.response.after` 仍只处理 non-stream 完整响应。 +- `gateway.error` 失败后保留原始 host error response。 +- `log.beforePersist` 失败后保留原始 request log payload。 +- reserved hooks 继续被 manifest validation 拒绝。 + +### 验收 + +- 每个 active hook 有固定 fixture 测试:context trimming、合法 mutation、非法 mutation、failure policy、audit。 +- permission trimming 和 mutation enforcement 由同一份 descriptor 驱动。 +- 无插件时 request path 和 stream path 继续保持 fast path。 +- 新增 hook 的最小步骤变为:添加 contract descriptor、添加调用点、添加 fixture。 + +## Runtime Layer + +### 当前短板 + +Runtime dispatch 已经能分发 `declarativeRules`、官方 privacy filter 和 policy-gated WASM,但 runtime 选择、缓存、policy、错误分类、性能预算与 pipeline 职责仍有耦合。后续如果开放 WASM 或 process runtime,复杂性容易回流到 gateway pipeline。 + +### 设计 + +新增内部 `PluginRuntimeManager`,保持外部 runtime 声明不变。职责包括: + +- 选择 runtime:`declarativeRules`、official native、policy-gated WASM。 +- 执行 policy:WASM 默认禁用;process runtime 不开放。 +- cache lifecycle:按 plugin id、version、installed dir、updated_at、runtime key 缓存与清理。 +- execution envelope:统一 trace id、config、visible context、result normalization。 +- error taxonomy:runtime disabled、unsupported runtime、load failed、execute failed、timeout、unauthorized mutation。 +- performance budget:空 pipeline、noop declarative plugin、规则插件、official privacy filter 均有 smoke budget。 + +模块边界建议: + +```text +src-tauri/src/app/plugins/ + runtime_manager.rs + runtime_policy.rs + runtime_cache.rs + runtimes/ + declarative_rules.rs + official_privacy_filter.rs + wasm_policy.rs +``` + +`GatewayPluginPipeline` 不直接关心具体 runtime,只调用 `PluginRuntimeManager.execute(invocation)`。Pipeline 负责 hook 顺序、timeout、circuit、audit;Runtime Manager 负责加载、缓存、执行和错误归一。 + +### 行为保持 + +- `declarativeRules` 仍是唯一稳定社区 runtime。 +- `official.privacy-filter` 仍是唯一官方 native engine。 +- 第三方 `native` 继续被拒绝。 +- WASM manifest 可校验,但默认不能运行。 +- runtime failure policy 仍由 hook/pipeline 决定。 + +### 验收 + +- 禁用、卸载、升级插件会清理 runtime cache。 +- WASM 默认返回稳定、明确的 policy disabled 错误。 +- runtime 错误码进入 audit/log,并保持可诊断。 +- plugin-focused Rust tests 覆盖 runtime dispatch。 +- 性能 smoke 证明无插件和 noop 插件没有明显退化。 + +## Provider Adapter Layer + +### 当前短板 + +Provider 特例逻辑分散在 gateway handler、middleware、failover prepare、attempt auth、request sanitizer、response translation 和 request-end logging 中。例如 CX2CC count_tokens、Gemini OAuth body/response translation、Codex ChatGPT 兼容头、Claude model mapping 等。继续按特例扩展会提高 gateway hot path 复杂度,未来开放 provider 能力时也缺少稳定内部边界。 + +### 设计 + +新增内部 provider adapter/capability 层,不开放为插件 API。目标是把 provider-specific 行为从 gateway orchestration 中收敛为描述式能力和阶段性处理接口。 + +建议内部模型: + +```rust +ProviderAdapter { + id, + capabilities, + prepare_request, + inject_auth, + before_send, + translate_response_headers, + translate_response_body, + translate_stream_chunk, + classify_error, + append_observability, +} +``` + +Capabilities 表达 provider 或 bridge 的能力,而不是让 gateway 到处判断字符串: + +- `anthropic_compatible` +- `openai_responses_compatible` +- `codex_chatgpt_backend` +- `gemini_oauth` +- `cx2cc_bridge` +- `supports_count_tokens_local_intercept` +- `requires_service_tier_adjustment` +- `supports_stream_idle_timeout_override` + +Provider selection、failover、circuit breaker、usage accounting 仍属于 gateway core。Provider adapter 只处理 provider-specific preparation、auth、translation、classification 和 observability hints。 + +0.62.0 不要求一次删除所有 legacy provider helper 函数,但要求 gateway orchestration 通过 adapter/capability facade 访问 provider-specific 行为。迁移早期可以让 adapter facade 委托现有函数;完成时,新增 provider 特例不应继续直接散落到 middleware、attempt 和 response 文件中。 + +### 行为保持 + +- 现有 provider 排序、session binding、forced provider、circuit、cooldown、limits 行为不改变。 +- CX2CC、Gemini OAuth、Codex/Claude 兼容逻辑迁移后输出必须与现有 fixture 一致。 +- request log、usage、provider chain、special settings JSON 形状保持兼容。 +- provider adapter 不允许第三方插件注册,不新增外部 API。 + +### 验收 + +- 为每个迁移的 provider 特例建立 before/after golden fixture。 +- Gateway failover loop 的 provider-specific 分支减少,orchestration 更聚焦。 +- 所有现有 provider-specific 决策都能在 adapter/capability facade 中定位,即使部分实现仍委托 legacy helper。 +- 新增内部 provider 特例时优先新增 adapter capability,而不是改 gateway hot path。 +- 迁移后 `cargo test` 中 provider、gateway、usage、plugin 相关测试保持通过。 + +## 前后端分工 + +Rust/Tauri host 是插件系统的执行与权限边界: + +- manifest validation +- package install/update/rollback +- runtime execution +- hook context trimming +- mutation enforcement +- provider adapter +- audit persistence +- gateway hot path + +React frontend 只负责: + +- 插件列表、详情、配置表单。 +- 权限展示与用户操作入口。 +- 审计日志展示。 +- 市场索引展示与安装入口。 +- 通过 Specta generated IPC 调用 Rust commands。 + +前端不运行插件代码,不解释 provider adapter,不绕过 Rust validation。 + +## 迁移顺序 + +1. Baseline:记录当前 plugin/gateway/provider 测试与性能基线。 +2. Contract:升级 contract checker,保持外部 API 不变。 +3. Hook Registry:引入 descriptor 与 mutation/context 分层,逐步迁移现有 hooks。 +4. Runtime:引入 runtime manager、policy、cache lifecycle 和错误 taxonomy。 +5. Provider Adapter:以 CX2CC 或 Gemini OAuth 为第一条迁移样本,再迁移 Codex/Claude 特例。 +6. Cleanup:移除迁移后重复的 string/match/特例逻辑。 +7. Verification:运行前端 typecheck、plugin checks、Rust plugin/gateway/provider tests。 + +## 测试策略 + +- Contract tests:校验 JSON contract、Rust validation、TS SDK、docs、scaffold/replay 一致。 +- Hook fixture tests:每个 hook 覆盖 context、permission、mutation、failure policy。 +- Runtime tests:覆盖 declarative rules、official native、WASM disabled、cache eviction。 +- Provider golden tests:迁移 provider 特例前后输出一致。 +- Gateway integration tests:覆盖 request afterBodyRead、beforeSend、response after、stream chunk、error、log。 +- Performance smoke:空 pipeline、noop declarative plugin、规则插件、privacy filter、stream direct path。 + +## 风险与控制 + +- 风险:一次性改动大,影响 gateway hot path。 + 控制:按层迁移,每层有 baseline 与行为一致性测试。 + +- 风险:contract checker 过强导致开发成本上升。 + 控制:错误信息必须指向具体 drift,且优先强校验而不是立刻全量生成。 + +- 风险:provider adapter 抽象过度。 + 控制:只迁移已有 provider 特例,不为空想 provider 能力设计公开 API。 + +- 风险:runtime manager 与 pipeline 边界不清。 + 控制:pipeline 只负责 hook orchestration;runtime manager 只负责执行与缓存。 + +## 非目标 + +- 不新增 Plugin API v2。 +- 不开放 provider 插件 API。 +- 不开放第三方 JavaScript/TypeScript runtime。 +- 不让插件运行在 Tauri WebView。 +- 不重做插件市场商业、签名信任体系或企业策略中心。 +- 不把 Skill 市场改造成插件 runtime。 + +## 完成定义 + +0.62.0 完成时,应满足: + +- 外部 Plugin API v1 兼容。 +- contract drift 能被结构化检查发现。 +- hook 语义集中在 registry/descriptor 中。 +- runtime dispatch、policy、cache、错误分类有清晰内部边界。 +- provider-specific 行为通过 adapter/capability facade 统一定位,高风险转换逻辑优先迁移,低风险 legacy helper 只能作为 facade 内部委托存在。 +- 现有 gateway/provider/plugin 功能测试通过。 +- 性能 smoke 没有暴露明显 hot path 退化。 +- 文档明确说明 0.62.0 是内部平台化版本,不新增外部插件 API。 + +## 参考链接 + +- VS Code Contribution Points: https://code.visualstudio.com/api/references/contribution-points +- VS Code Extension Host: https://code.visualstudio.com/api/advanced-topics/extension-host +- Chrome Extension Permissions: https://developer.chrome.com/docs/extensions/develop/concepts/declare-permissions +- Chrome Declarative Net Request: https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest +- Tauri 2 Plugins: https://v2.tauri.app/develop/plugins/ +- Kong Gateway Custom Plugins: https://developer.konghq.com/custom-plugins/handler.lua/ +- Backstage Plugin System: https://backstage.io/docs/plugins/ +- JetBrains Extension Points: https://plugins.jetbrains.com/docs/intellij/plugin-extension-points.html From 71ef3f0b6e064f0c1d3687e44e5a3577db0ef75a Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Sun, 21 Jun 2026 22:28:34 +0800 Subject: [PATCH 002/244] docs: plan plugin platform kernel implementation --- ...-coding-hub-0-62-plugin-platform-kernel.md | 1603 +++++++++++++++++ 1 file changed, 1603 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel.md diff --git a/docs/superpowers/plans/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel.md b/docs/superpowers/plans/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel.md new file mode 100644 index 00000000..7a8c7e4c --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel.md @@ -0,0 +1,1603 @@ +# aio-coding-hub 0.62.0 Plugin Platform Kernel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rework the plugin platform internals around a contract-driven hook, runtime, and provider-adapter kernel while preserving Plugin API v1 external behavior. + +**Architecture:** Keep `docs/plugins/plugin-api-v1-contract.json` as the canonical public contract, then derive or structurally check Rust, TypeScript, docs, scaffold, and runtime behavior against it. Introduce internal registries and facades in layers: contract metadata, hook descriptors, mutation/context enforcement, runtime manager, and provider adapter facade. No new public plugin API is added in 0.62.0. + +**Tech Stack:** Rust/Tauri 2/Axum/SQLite/Specta, TypeScript/React/Vite/Vitest, Node.js contract scripts, Cargo tests, pnpm workspace tooling. + +--- + +## Scope Note + +The design covers Contract, Hook Registry, Runtime, and Provider Adapter layers. These are not independent product features; they are dependent platform layers. This plan keeps them in one master implementation plan so each task can validate external Plugin API v1 compatibility before the next layer builds on it. + +## File Responsibility Map + +- `docs/plugins/plugin-api-v1-contract.json`: canonical Plugin API v1 contract. +- `scripts/check-plugin-api-contract.mjs`: structural drift checker for contract, Rust, SDK, docs, scaffold, and WASM SDK. +- `scripts/check-plugin-api-contract.selftest.mjs`: checker regression tests using temporary fixture repositories. +- `src-tauri/src/gateway/plugins/contract.rs`: Rust view of hook, permission, runtime, timeout, and mutation metadata. +- `src-tauri/src/gateway/plugins/registry.rs`: internal `HookRegistry` and `HookDescriptor` lookup APIs. +- `src-tauri/src/gateway/plugins/mutation.rs`: hook result permission enforcement and mutation application helpers. +- `src-tauri/src/gateway/plugins/context.rs`: visible hook context types and context builders. +- `src-tauri/src/gateway/plugins/pipeline.rs`: hook ordering, timeout, circuit, audit, and executor orchestration. +- `src-tauri/src/app/plugins/runtime_manager.rs`: runtime dispatch facade used by gateway plugin pipeline. +- `src-tauri/src/app/plugins/runtime_policy.rs`: host runtime policy for WASM/process/native runtime availability. +- `src-tauri/src/app/plugins/runtime_cache.rs`: cache key and cache retention helpers shared by runtime implementations. +- `src-tauri/src/app/plugins/runtimes/declarative_rules.rs`: declarative rules runtime module after extraction from current rule runtime. +- `src-tauri/src/app/plugins/runtimes/official_privacy_filter.rs`: official native privacy filter module after extraction. +- `src-tauri/src/app/plugins/runtimes/wasm_policy.rs`: policy-gated WASM runtime entrypoint that returns stable disabled errors. +- `src-tauri/src/app/plugins/runtime_executor.rs`: temporary compatibility wrapper until call sites use `PluginRuntimeManager`. +- `src-tauri/src/gateway/proxy/provider_adapters/mod.rs`: provider adapter facade and registry. +- `src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs`: CX2CC adapter facade. +- `src-tauri/src/gateway/proxy/provider_adapters/gemini_oauth.rs`: Gemini OAuth adapter facade. +- `src-tauri/src/gateway/proxy/provider_adapters/codex_chatgpt.rs`: Codex ChatGPT adapter facade. +- `src-tauri/src/gateway/proxy/provider_adapters/claude.rs`: Claude model mapping and Claude-specific adapter facade. +- `src-tauri/src/gateway/proxy/handler/*`: gateway call sites that should call facades instead of adding new provider-specific branches. +- `packages/plugin-sdk/src/index.ts`: public TypeScript Plugin API v1 SDK, unchanged externally. +- `packages/create-aio-plugin/src/devtools.ts`: author tooling replay/pack behavior, unchanged externally. +- `docs/plugins/reference/*.md`: public docs, updated only to describe internal 0.62 compatibility guarantees where needed. + +## Baseline Verification + +### Task 0: Record Current State + +**Files:** +- Read: `package.json` +- Read: `docs/plugins/plugin-api-v1-contract.json` +- Read: `docs/superpowers/specs/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel-design.md` +- No source changes + +- [ ] **Step 1: Confirm a clean or understood worktree** + +Run: + +```bash +git status --short +``` + +Expected: either no output, or only files intentionally owned by this task. Do not revert unrelated user changes. + +- [ ] **Step 2: Run plugin contract checks** + +Run: + +```bash +pnpm check:plugin-api-contract +pnpm check:plugin-system-docs +pnpm check:plugin-system-completion +``` + +Expected: all pass before refactor work begins. If a command fails, record the failing command and exact output in the task journal before changing code. + +- [ ] **Step 3: Run SDK and author-tool tests** + +Run: + +```bash +pnpm plugin-sdk:typecheck +pnpm --filter @aio-coding-hub/plugin-sdk test +pnpm create-aio-plugin:test +pnpm plugin-wasm-sdk:test +``` + +Expected: all pass before refactor work begins. + +- [ ] **Step 4: Run Rust plugin/gateway/provider baseline tests** + +Run: + +```bash +cd src-tauri && cargo test plugin --lib +cd src-tauri && cargo test provider --lib +cd src-tauri && cargo test gateway --lib +``` + +Expected: all pass, or any pre-existing failures are documented with exact test names. + +- [ ] **Step 5: Commit baseline note only if files were changed** + +If no files changed, do not commit. If a local journal file was intentionally updated, run: + +```bash +git add +git commit -m "test: record plugin platform baseline" +``` + +Expected: either no commit is created, or exactly the journal file is committed. + +## Contract Layer + +### Task 1: Extend the Contract JSON Shape Without Changing Public API + +**Files:** +- Modify: `docs/plugins/plugin-api-v1-contract.json` +- Modify: `scripts/check-plugin-api-contract.mjs` +- Modify: `scripts/check-plugin-api-contract.selftest.mjs` +- Test: `scripts/check-plugin-api-contract.selftest.mjs` + +- [ ] **Step 1: Write a failing self-test for missing hook matrix fields** + +Modify `scripts/check-plugin-api-contract.selftest.mjs` so its temporary `plugin-api-v1-contract.json` includes an active hook entry missing `kind`, `status`, or `mutationFields`. Add this assertion near the existing spawn check: + +```javascript +if (result.status === 0 || !result.stderr.includes("hookMatrix.gateway.request.afterBodyRead.kind")) { + throw new Error(`expected hookMatrix kind failure, got status ${result.status}\n${result.stderr}`); +} +``` + +- [ ] **Step 2: Run the self-test and verify it fails** + +Run: + +```bash +node scripts/check-plugin-api-contract.selftest.mjs +``` + +Expected: FAIL because `scripts/check-plugin-api-contract.mjs` does not yet validate structured hook matrix metadata. + +- [ ] **Step 3: Add structured fields to the canonical contract** + +Update every `hookMatrix` entry in `docs/plugins/plugin-api-v1-contract.json` to include these exact fields: + +```json +{ + "kind": "request", + "status": "active", + "defaultFailurePolicy": "fail-open", + "timeoutMs": 150, + "reservedHeaderPolicy": "block-gateway-owned" +} +``` + +Use `kind` values `request`, `response`, `stream`, and `log`. Use `status: "active"` for active hooks. Keep the existing active/reserved hook arrays unchanged. + +- [ ] **Step 4: Implement structured checker helpers** + +In `scripts/check-plugin-api-contract.mjs`, add helpers: + +```javascript +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 requireOneOf(path, value, allowed) { + if (!allowed.includes(value)) { + failures.push(`${path} must be one of ${allowed.join(", ")}`); + } +} +``` + +Then validate each `contract.activeHooks` entry: + +```javascript +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"]); + requireArray(`hookMatrix.${hook}.readPermissions`, entry.readPermissions); + requireArray(`hookMatrix.${hook}.writePermissions`, entry.writePermissions); + requireArray(`hookMatrix.${hook}.mutationFields`, entry.mutationFields); + requireArray(`hookMatrix.${hook}.contextFields`, entry.contextFields); + if (entry.timeoutMs !== contract.defaultHookTimeoutMs) { + failures.push(`hookMatrix.${hook}.timeoutMs must equal defaultHookTimeoutMs`); + } +} +``` + +- [ ] **Step 5: Run the self-test and verify it passes** + +Run: + +```bash +node scripts/check-plugin-api-contract.selftest.mjs +``` + +Expected: PASS because the checker detects the intentionally incomplete fixture. + +- [ ] **Step 6: Run the real contract check** + +Run: + +```bash +pnpm check:plugin-api-contract +``` + +Expected: PASS against the real repository. + +- [ ] **Step 7: Commit contract checker hardening** + +Run: + +```bash +git add docs/plugins/plugin-api-v1-contract.json scripts/check-plugin-api-contract.mjs scripts/check-plugin-api-contract.selftest.mjs +git commit -m "test: harden plugin api contract checker" +``` + +Expected: commit contains only contract JSON and checker files. + +### Task 2: Add Rust Contract Metadata + +**Files:** +- Create: `src-tauri/src/gateway/plugins/contract.rs` +- Modify: `src-tauri/src/gateway/plugins/mod.rs` +- Modify: `src-tauri/src/domain/plugins.rs` +- Test: Rust unit tests in `src-tauri/src/gateway/plugins/contract.rs` + +- [ ] **Step 1: Write Rust metadata tests** + +Create `src-tauri/src/gateway/plugins/contract.rs` with tests first: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn active_hook_metadata_matches_plugin_api_v1() { + let ids: Vec<&'static str> = ACTIVE_HOOKS.iter().map(|hook| hook.id).collect(); + assert_eq!( + ids, + vec![ + "gateway.request.afterBodyRead", + "gateway.request.beforeSend", + "gateway.response.chunk", + "gateway.response.after", + "gateway.error", + "log.beforePersist", + ] + ); + } + + #[test] + fn reserved_hook_metadata_matches_plugin_api_v1() { + assert!(is_reserved_hook("gateway.request.received")); + assert!(is_reserved_hook("gateway.request.beforeProviderResolution")); + assert!(is_reserved_hook("gateway.response.headers")); + assert!(!is_reserved_hook("gateway.response.after")); + } + + #[test] + fn permission_metadata_marks_reserved_permissions() { + assert!(is_reserved_permission("network.fetch")); + assert!(is_reserved_permission("file.read")); + assert!(!is_reserved_permission("request.body.read")); + } +} +``` + +- [ ] **Step 2: Run the new Rust test and verify it fails to compile** + +Run: + +```bash +cd src-tauri && cargo test active_hook_metadata_matches_plugin_api_v1 --lib +``` + +Expected: FAIL because `ACTIVE_HOOKS`, `is_reserved_hook`, and `is_reserved_permission` are not implemented yet. + +- [ ] **Step 3: Implement Rust contract metadata** + +Add the implementation above the tests in `contract.rs`: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HookKind { + Request, + Response, + Stream, + Log, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HookStatus { + Active, + Reserved, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct HookContract { + pub(crate) id: &'static str, + pub(crate) kind: HookKind, + pub(crate) status: HookStatus, + 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, +} + +pub(crate) const DEFAULT_HOOK_TIMEOUT_MS: u64 = 150; +pub(crate) const DEFAULT_FAILURE_POLICY: &str = "fail-open"; + +pub(crate) const ACTIVE_HOOKS: &[HookContract] = &[ + HookContract { + id: "gateway.request.afterBodyRead", + kind: HookKind::Request, + status: HookStatus::Active, + read_permissions: &[ + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.body.read", + ], + write_permissions: &["request.header.write", "request.body.write"], + mutation_fields: &["headers", "requestBody"], + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + default_failure_policy: DEFAULT_FAILURE_POLICY, + }, + HookContract { + id: "gateway.request.beforeSend", + kind: HookKind::Request, + status: HookStatus::Active, + read_permissions: &[ + "request.meta.read", + "request.header.read", + "request.header.readSensitive", + "request.body.read", + ], + write_permissions: &["request.header.write", "request.body.write"], + mutation_fields: &["headers", "requestBody"], + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + default_failure_policy: DEFAULT_FAILURE_POLICY, + }, + HookContract { + id: "gateway.response.chunk", + kind: HookKind::Stream, + status: HookStatus::Active, + read_permissions: &["stream.inspect"], + write_permissions: &["stream.modify"], + mutation_fields: &["streamChunk"], + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + default_failure_policy: DEFAULT_FAILURE_POLICY, + }, + HookContract { + id: "gateway.response.after", + kind: HookKind::Response, + status: HookStatus::Active, + read_permissions: &["response.header.read", "response.body.read"], + write_permissions: &["response.header.write", "response.body.write"], + mutation_fields: &["headers", "responseBody"], + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + default_failure_policy: DEFAULT_FAILURE_POLICY, + }, + HookContract { + id: "gateway.error", + kind: HookKind::Response, + status: HookStatus::Active, + read_permissions: &["response.header.read", "response.body.read"], + write_permissions: &["response.header.write", "response.body.write"], + mutation_fields: &["headers", "responseBody"], + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + default_failure_policy: DEFAULT_FAILURE_POLICY, + }, + HookContract { + id: "log.beforePersist", + kind: HookKind::Log, + status: HookStatus::Active, + read_permissions: &["log.redact"], + write_permissions: &["log.redact"], + mutation_fields: &["logMessage"], + timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, + default_failure_policy: DEFAULT_FAILURE_POLICY, + }, +]; + +pub(crate) const RESERVED_HOOKS: &[&str] = &[ + "gateway.request.received", + "gateway.request.beforeProviderResolution", + "gateway.response.headers", +]; + +pub(crate) const RESERVED_PERMISSIONS: &[&str] = &[ + "plugin.storage", + "network.fetch", + "file.read", + "file.write", + "secret.read", +]; + +pub(crate) fn hook_contract(id: &str) -> Option<&'static HookContract> { + ACTIVE_HOOKS.iter().find(|hook| hook.id == id) +} + +pub(crate) fn is_active_hook(id: &str) -> bool { + hook_contract(id).is_some() +} + +pub(crate) fn is_reserved_hook(id: &str) -> bool { + RESERVED_HOOKS.iter().any(|hook| *hook == id) +} + +pub(crate) fn is_known_hook(id: &str) -> bool { + is_active_hook(id) || is_reserved_hook(id) +} + +pub(crate) fn is_reserved_permission(permission: &str) -> bool { + RESERVED_PERMISSIONS.iter().any(|item| *item == permission) +} +``` + +- [ ] **Step 4: Export the contract module** + +Modify `src-tauri/src/gateway/plugins/mod.rs`: + +```rust +pub(crate) mod audit; +pub(crate) mod context; +pub(crate) mod contract; +pub(crate) mod permissions; +pub(crate) mod pipeline; +``` + +- [ ] **Step 5: Use contract metadata in domain validation** + +In `src-tauri/src/domain/plugins.rs`, replace the bodies of `is_known_hook`, `is_active_gateway_hook`, `is_reserved_gateway_hook`, and `is_reserved_permission`: + +```rust +pub fn is_known_hook(hook: &str) -> bool { + crate::gateway::plugins::contract::is_known_hook(hook) +} + +pub fn is_active_gateway_hook(hook: &str) -> bool { + crate::gateway::plugins::contract::is_active_hook(hook) +} + +pub fn is_reserved_gateway_hook(hook: &str) -> bool { + crate::gateway::plugins::contract::is_reserved_hook(hook) +} + +pub fn is_reserved_permission(permission: &str) -> bool { + crate::gateway::plugins::contract::is_reserved_permission(permission) +} +``` + +- [ ] **Step 6: Run focused Rust tests** + +Run: + +```bash +cd src-tauri && cargo test active_hook_metadata_matches_plugin_api_v1 --lib +cd src-tauri && cargo test reserved_hook_metadata_matches_plugin_api_v1 --lib +cd src-tauri && cargo test plugin_manifest --lib +``` + +Expected: all pass. + +- [ ] **Step 7: Run contract drift check** + +Run: + +```bash +pnpm check:plugin-api-contract +``` + +Expected: PASS. + +- [ ] **Step 8: Commit Rust contract metadata** + +Run: + +```bash +git add src-tauri/src/gateway/plugins/contract.rs src-tauri/src/gateway/plugins/mod.rs src-tauri/src/domain/plugins.rs +git commit -m "refactor(plugins): centralize rust plugin contract metadata" +``` + +Expected: commit contains only Rust contract metadata and validation call-through changes. + +## Hook Registry Layer + +### Task 3: Introduce Hook Registry Descriptors + +**Files:** +- Create: `src-tauri/src/gateway/plugins/registry.rs` +- Modify: `src-tauri/src/gateway/plugins/mod.rs` +- Modify: `src-tauri/src/gateway/plugins/context.rs` +- Test: Rust unit tests in `src-tauri/src/gateway/plugins/registry.rs` + +- [ ] **Step 1: Write registry tests** + +Create `registry.rs` with tests: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::gateway::plugins::context::GatewayPluginHookName; + + #[test] + fn registry_resolves_active_request_hook() { + let registry = HookRegistry::new(); + let descriptor = registry + .descriptor(GatewayPluginHookName::RequestAfterBodyRead) + .expect("descriptor"); + assert_eq!(descriptor.id, "gateway.request.afterBodyRead"); + assert_eq!(descriptor.kind, HookKind::Request); + assert!(descriptor.allows_read_permission("request.body.read")); + assert!(descriptor.allows_mutation_field("requestBody")); + } + + #[test] + fn registry_marks_stream_chunk_as_stream_kind() { + let registry = HookRegistry::new(); + let descriptor = registry + .descriptor(GatewayPluginHookName::ResponseChunk) + .expect("descriptor"); + assert_eq!(descriptor.kind, HookKind::Stream); + assert!(descriptor.allows_read_permission("stream.inspect")); + assert!(descriptor.allows_write_permission("stream.modify")); + } +} +``` + +- [ ] **Step 2: Run registry tests and verify compile failure** + +Run: + +```bash +cd src-tauri && cargo test registry_resolves_active_request_hook --lib +``` + +Expected: FAIL because `HookRegistry` is not implemented. + +- [ ] **Step 3: Implement HookRegistry** + +Add implementation above the tests: + +```rust +use super::contract::{self, HookKind}; +use super::context::GatewayPluginHookName; + +#[derive(Debug, Clone, Copy)] +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.iter().any(|item| *item == permission) + } + + pub(crate) fn allows_write_permission(self, permission: &str) -> bool { + self.write_permissions.iter().any(|item| *item == permission) + } + + pub(crate) fn allows_mutation_field(self, field: &str) -> bool { + self.mutation_fields.iter().any(|item| *item == field) + } +} + +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct HookRegistry; + +impl HookRegistry { + pub(crate) fn new() -> Self { + Self + } + + pub(crate) fn descriptor(self, hook_name: GatewayPluginHookName) -> Option { + let contract = 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, + }) + } +} +``` + +- [ ] **Step 4: Export registry module** + +Modify `src-tauri/src/gateway/plugins/mod.rs`: + +```rust +pub(crate) mod registry; +``` + +- [ ] **Step 5: Add string conversion helper on hook names** + +In `src-tauri/src/gateway/plugins/context.rs`, add: + +```rust +impl GatewayPluginHookName { + 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, + } + } +} +``` + +- [ ] **Step 6: Run registry tests** + +Run: + +```bash +cd src-tauri && cargo test registry_resolves_active_request_hook --lib +cd src-tauri && cargo test registry_marks_stream_chunk_as_stream_kind --lib +``` + +Expected: PASS. + +- [ ] **Step 7: Commit hook registry skeleton** + +Run: + +```bash +git add src-tauri/src/gateway/plugins/registry.rs src-tauri/src/gateway/plugins/mod.rs src-tauri/src/gateway/plugins/context.rs +git commit -m "refactor(plugins): add internal hook registry" +``` + +Expected: commit contains registry skeleton and hook string conversion. + +### Task 4: Move Mutation Enforcement Behind Descriptors + +**Files:** +- Create: `src-tauri/src/gateway/plugins/mutation.rs` +- Modify: `src-tauri/src/gateway/plugins/mod.rs` +- Modify: `src-tauri/src/gateway/plugins/permissions.rs` +- Modify: `src-tauri/src/gateway/plugins/pipeline.rs` +- Test: Rust unit tests in `mutation.rs` + +- [ ] **Step 1: Write descriptor-driven mutation tests** + +Create `mutation.rs` with tests: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::gateway::plugins::context::{ + GatewayHookAction, GatewayHookResult, GatewayPluginHookName, + }; + use crate::gateway::plugins::registry::HookRegistry; + + #[test] + fn request_body_mutation_requires_descriptor_permission() { + let descriptor = HookRegistry::new() + .descriptor(GatewayPluginHookName::RequestBeforeSend) + .expect("descriptor"); + let result = GatewayHookResult { + action: GatewayHookAction::Continue, + request_body: Some("changed".to_string()), + response_body: None, + stream_chunk: None, + headers: Default::default(), + log_message: None, + reason: None, + }; + + let err = enforce_descriptor_permissions(descriptor, &[], &result) + .expect_err("missing write permission"); + assert_eq!(err.code_for_logging(), "PLUGIN_PERMISSION_DENIED"); + + enforce_descriptor_permissions( + descriptor, + &["request.body.write".to_string()], + &result, + ) + .expect("permission granted"); + } +} +``` + +- [ ] **Step 2: Add `code_for_logging` accessor** + +In `src-tauri/src/gateway/plugins/permissions.rs`, add a non-test accessor: + +```rust +impl GatewayPluginError { + pub(crate) fn code_for_logging(&self) -> &'static str { + self.code + } +} +``` + +Keep the existing `#[cfg(test)] fn code(&self)` until all tests are migrated. + +- [ ] **Step 3: Run mutation test and verify compile failure** + +Run: + +```bash +cd src-tauri && cargo test request_body_mutation_requires_descriptor_permission --lib +``` + +Expected: FAIL because `enforce_descriptor_permissions` does not exist. + +- [ ] **Step 4: Implement descriptor-driven enforcement** + +Add implementation above tests in `mutation.rs`: + +```rust +use super::context::GatewayHookResult; +use super::permissions::GatewayPluginError; +use super::registry::HookDescriptor; + +pub(crate) fn enforce_descriptor_permissions( + descriptor: HookDescriptor, + permissions: &[String], + result: &GatewayHookResult, +) -> Result<(), GatewayPluginError> { + if result.request_body.is_some() { + require_mutation(descriptor, "requestBody", "request.body.write")?; + require_permission(permissions, "request.body.write")?; + } + if result.response_body.is_some() { + require_mutation(descriptor, "responseBody", "response.body.write")?; + require_permission(permissions, "response.body.write")?; + } + if result.stream_chunk.is_some() { + require_mutation(descriptor, "streamChunk", "stream.modify")?; + require_permission(permissions, "stream.modify")?; + } + if result.log_message.is_some() { + require_mutation(descriptor, "logMessage", "log.redact")?; + require_permission(permissions, "log.redact")?; + } + if !result.headers.is_empty() { + require_mutation(descriptor, "headers", header_write_permission(descriptor)?)?; + require_permission(permissions, header_write_permission(descriptor)?)?; + } + Ok(()) +} + +fn header_write_permission(descriptor: HookDescriptor) -> Result<&'static str, GatewayPluginError> { + if descriptor.allows_write_permission("request.header.write") { + Ok("request.header.write") + } else if descriptor.allows_write_permission("response.header.write") { + Ok("response.header.write") + } else { + Err(GatewayPluginError::new( + "PLUGIN_PERMISSION_DENIED", + format!("headers cannot be mutated in {}", descriptor.id), + )) + } +} + +fn require_mutation( + descriptor: HookDescriptor, + field: &'static str, + permission: &'static str, +) -> Result<(), GatewayPluginError> { + if descriptor.allows_mutation_field(field) && descriptor.allows_write_permission(permission) { + Ok(()) + } else { + Err(GatewayPluginError::new( + "PLUGIN_PERMISSION_DENIED", + format!("{field} mutation is not allowed in {}", descriptor.id), + )) + } +} + +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}"), + )) + } +} +``` + +- [ ] **Step 5: Export mutation module** + +Modify `src-tauri/src/gateway/plugins/mod.rs`: + +```rust +pub(crate) mod mutation; +``` + +- [ ] **Step 6: Use descriptor enforcement in pipeline** + +In `src-tauri/src/gateway/plugins/pipeline.rs`, replace calls to `enforce_hook_result_permissions` with: + +```rust +let descriptor = crate::gateway::plugins::registry::HookRegistry::new() + .descriptor(input.hook_name) + .ok_or_else(|| GatewayPluginError::new( + "PLUGIN_UNKNOWN_HOOK", + format!("unknown plugin hook: {}", input.hook_name.as_str()), + ))?; +if let Err(err) = crate::gateway::plugins::mutation::enforce_descriptor_permissions( + descriptor, + &plugin.granted_permissions, + &result, +) { + // keep the existing failure/audit/fail-open or fail-closed branch unchanged +} +``` + +Apply the same pattern to request, response, stream, and log hook execution paths. + +- [ ] **Step 7: Keep compatibility wrapper** + +Leave `enforce_hook_result_permissions` in `permissions.rs` as a wrapper: + +```rust +pub(crate) fn enforce_hook_result_permissions( + hook_name: GatewayPluginHookName, + permissions: &[String], + result: &GatewayHookResult, +) -> Result<(), GatewayPluginError> { + let descriptor = crate::gateway::plugins::registry::HookRegistry::new() + .descriptor(hook_name) + .ok_or_else(|| { + GatewayPluginError::new( + "PLUGIN_UNKNOWN_HOOK", + format!("unknown plugin hook: {}", hook_name.as_str()), + ) + })?; + crate::gateway::plugins::mutation::enforce_descriptor_permissions( + descriptor, + permissions, + result, + ) +} +``` + +- [ ] **Step 8: Run mutation and pipeline tests** + +Run: + +```bash +cd src-tauri && cargo test request_body_mutation_requires_descriptor_permission --lib +cd src-tauri && cargo test gateway_plugin_context_permission_enforces_write_permissions --lib +cd src-tauri && cargo test gateway_plugin_response_pipeline_applies_body_and_header_changes --lib +``` + +Expected: all pass. + +- [ ] **Step 9: Commit mutation descriptor enforcement** + +Run: + +```bash +git add src-tauri/src/gateway/plugins/mutation.rs src-tauri/src/gateway/plugins/mod.rs src-tauri/src/gateway/plugins/permissions.rs src-tauri/src/gateway/plugins/pipeline.rs +git commit -m "refactor(plugins): enforce mutations through hook descriptors" +``` + +Expected: commit contains descriptor enforcement only. + +## Runtime Layer + +### Task 5: Introduce Runtime Policy and Runtime Manager Facade + +**Files:** +- Create: `src-tauri/src/app/plugins/runtime_policy.rs` +- Create: `src-tauri/src/app/plugins/runtime_manager.rs` +- Modify: `src-tauri/src/app/plugins/mod.rs` +- Modify: `src-tauri/src/app/plugins/runtime_executor.rs` +- Test: Rust unit tests in `runtime_manager.rs` + +- [ ] **Step 1: Write runtime manager tests** + +Create `runtime_manager.rs` with tests: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::app::plugins::runtime_policy::RuntimePolicy; + use crate::domain::plugins::PluginRuntime; + + #[test] + fn runtime_manager_rejects_wasm_when_policy_disabled() { + let manager = PluginRuntimeManager::for_tests(RuntimePolicy { + wasm_enabled: false, + process_enabled: false, + }); + let err = manager + .validate_runtime_policy(&PluginRuntime::Wasm { + abi_version: "1.0.0".to_string(), + memory_limit_bytes: Some(16 * 1024 * 1024), + }) + .expect_err("wasm disabled"); + assert_eq!(err.code_for_logging(), "PLUGIN_RUNTIME_DISABLED"); + } + + #[test] + fn runtime_manager_allows_declarative_rules_policy() { + let manager = PluginRuntimeManager::for_tests(RuntimePolicy::default()); + manager + .validate_runtime_policy(&PluginRuntime::DeclarativeRules { + rules: vec!["rules/main.json".to_string()], + }) + .expect("declarative rules allowed"); + } +} +``` + +- [ ] **Step 2: Add runtime policy module** + +Create `runtime_policy.rs`: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RuntimePolicy { + pub(crate) wasm_enabled: bool, + pub(crate) process_enabled: bool, +} + +impl Default for RuntimePolicy { + fn default() -> Self { + Self { + wasm_enabled: false, + process_enabled: false, + } + } +} +``` + +- [ ] **Step 3: Run tests and verify compile failure** + +Run: + +```bash +cd src-tauri && cargo test runtime_manager_rejects_wasm_when_policy_disabled --lib +``` + +Expected: FAIL because `PluginRuntimeManager` is not implemented. + +- [ ] **Step 4: Implement runtime manager policy facade** + +Add implementation above tests in `runtime_manager.rs`: + +```rust +use crate::app::plugins::runtime_policy::RuntimePolicy; +use crate::domain::plugins::PluginRuntime; +use crate::gateway::plugins::permissions::GatewayPluginError; + +#[derive(Default)] +pub(crate) struct PluginRuntimeManager { + policy: RuntimePolicy, +} + +impl PluginRuntimeManager { + #[cfg(test)] + pub(crate) fn for_tests(policy: RuntimePolicy) -> Self { + Self { policy } + } + + pub(crate) fn new(policy: RuntimePolicy) -> Self { + Self { policy } + } + + pub(crate) fn validate_runtime_policy( + &self, + runtime: &PluginRuntime, + ) -> Result<(), GatewayPluginError> { + match runtime { + PluginRuntime::DeclarativeRules { .. } => Ok(()), + PluginRuntime::Native { engine } if engine == "privacyFilter" => Ok(()), + PluginRuntime::Native { engine } => Err(GatewayPluginError::new( + "PLUGIN_UNSUPPORTED_RUNTIME", + format!("unsupported native plugin runtime engine: {engine}"), + )), + PluginRuntime::Wasm { .. } if !self.policy.wasm_enabled => { + Err(GatewayPluginError::new( + "PLUGIN_RUNTIME_DISABLED", + "wasm runtime execution is disabled by host policy", + )) + } + PluginRuntime::Wasm { .. } => Err(GatewayPluginError::new( + "PLUGIN_WASM_NOT_WIRED", + "wasm runtime policy is enabled but gateway execution is not wired in this release", + )), + } + } +} +``` + +- [ ] **Step 5: Export runtime modules** + +Modify `src-tauri/src/app/plugins/mod.rs`: + +```rust +pub(crate) mod runtime_manager; +pub(crate) mod runtime_policy; +``` + +- [ ] **Step 6: Delegate old executor policy checks** + +In `runtime_executor.rs`, keep the public struct but call the manager: + +```rust +let manager = crate::app::plugins::runtime_manager::PluginRuntimeManager::new( + crate::app::plugins::runtime_policy::RuntimePolicy { + wasm_enabled: self.policy.wasm_enabled, + process_enabled: false, + }, +); +manager.validate_runtime_policy(&plugin.manifest.runtime)?; +``` + +Place this at the start of `execute_plugin_sync`, then keep the existing declarative/native dispatch branches. The WASM branches become unreachable after policy validation, so return the same existing errors if they are still matched. + +- [ ] **Step 7: Run focused runtime tests** + +Run: + +```bash +cd src-tauri && cargo test runtime_manager_rejects_wasm_when_policy_disabled --lib +cd src-tauri && cargo test runtime_executor_returns_clear_error_for_policy_disabled_wasm --lib +``` + +Expected: all pass and error code remains `PLUGIN_RUNTIME_DISABLED`. + +- [ ] **Step 8: Commit runtime manager facade** + +Run: + +```bash +git add src-tauri/src/app/plugins/runtime_manager.rs src-tauri/src/app/plugins/runtime_policy.rs src-tauri/src/app/plugins/mod.rs src-tauri/src/app/plugins/runtime_executor.rs +git commit -m "refactor(plugins): introduce runtime manager policy facade" +``` + +Expected: commit keeps external runtime behavior unchanged. + +### Task 6: Extract Runtime Cache Helpers + +**Files:** +- Create: `src-tauri/src/app/plugins/runtime_cache.rs` +- Modify: `src-tauri/src/app/plugins/mod.rs` +- Modify: `src-tauri/src/app/plugins/rule_runtime.rs` +- Test: Rust unit tests in `runtime_cache.rs` + +- [ ] **Step 1: Write cache key tests** + +Create `runtime_cache.rs` with tests: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cache_key_changes_with_updated_at() { + let a = RuntimeCacheKeyInput { + plugin_id: "acme.redactor", + version: "1.0.0", + installed_dir: "/tmp/acme", + updated_at: 1, + runtime_key: "rules/main.json", + }; + let b = RuntimeCacheKeyInput { updated_at: 2, ..a }; + assert_ne!(runtime_cache_key(a), runtime_cache_key(b)); + } + + #[test] + fn cache_key_includes_runtime_key() { + let a = RuntimeCacheKeyInput { + plugin_id: "acme.redactor", + version: "1.0.0", + installed_dir: "/tmp/acme", + updated_at: 1, + runtime_key: "rules/a.json", + }; + let b = RuntimeCacheKeyInput { + runtime_key: "rules/b.json", + ..a + }; + assert_ne!(runtime_cache_key(a), runtime_cache_key(b)); + } +} +``` + +- [ ] **Step 2: Implement runtime cache key helper** + +Add above tests: + +```rust +#[derive(Debug, Clone, Copy)] +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 + ) +} +``` + +- [ ] **Step 3: Export runtime cache module** + +Modify `src-tauri/src/app/plugins/mod.rs`: + +```rust +pub(crate) mod runtime_cache; +``` + +- [ ] **Step 4: Use helper in rule runtime cache keys** + +In `src-tauri/src/app/plugins/rule_runtime.rs`, replace the string formatting in `rule_runtime_cache_key` with: + +```rust +crate::app::plugins::runtime_cache::runtime_cache_key( + crate::app::plugins::runtime_cache::RuntimeCacheKeyInput { + plugin_id: &plugin.summary.plugin_id, + version, + installed_dir, + updated_at, + runtime_key: &rules, + }, +) +``` + +Replace `privacy_filter_cache_key` similarly, using runtime key `"native:privacyFilter"`. + +- [ ] **Step 5: Run cache and rule runtime tests** + +Run: + +```bash +cd src-tauri && cargo test cache_key_changes_with_updated_at --lib +cd src-tauri && cargo test cache_key_includes_runtime_key --lib +cd src-tauri && cargo test rule_runtime --lib +``` + +Expected: all pass. + +- [ ] **Step 6: Commit runtime cache extraction** + +Run: + +```bash +git add src-tauri/src/app/plugins/runtime_cache.rs src-tauri/src/app/plugins/mod.rs src-tauri/src/app/plugins/rule_runtime.rs +git commit -m "refactor(plugins): share runtime cache key helpers" +``` + +Expected: commit contains cache helper extraction only. + +## Provider Adapter Layer + +### Task 7: Add Provider Adapter Facade Without Moving Behavior + +**Files:** +- Create: `src-tauri/src/gateway/proxy/provider_adapters/mod.rs` +- Create: `src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs` +- Create: `src-tauri/src/gateway/proxy/provider_adapters/gemini_oauth.rs` +- Create: `src-tauri/src/gateway/proxy/provider_adapters/codex_chatgpt.rs` +- Create: `src-tauri/src/gateway/proxy/provider_adapters/claude.rs` +- Modify: `src-tauri/src/gateway/proxy/mod.rs` +- Test: Rust unit tests in `provider_adapters/mod.rs` + +- [ ] **Step 1: Write provider adapter registry tests** + +Create `provider_adapters/mod.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_identifies_cx2cc_bridge_capability() { + let caps = ProviderCapabilities { + cx2cc_bridge: true, + ..ProviderCapabilities::default() + }; + assert!(caps.cx2cc_bridge); + assert!(caps.supports_count_tokens_local_intercept()); + } + + #[test] + fn registry_default_capabilities_are_plain_provider() { + let caps = ProviderCapabilities::default(); + assert!(!caps.cx2cc_bridge); + assert!(!caps.gemini_oauth); + assert!(!caps.codex_chatgpt_backend); + } +} +``` + +- [ ] **Step 2: Implement provider capability facade** + +Add above tests: + +```rust +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 + } +} +``` + +- [ ] **Step 3: Add empty adapter modules with explicit purpose** + +Create each module with a narrow compatibility shim. + +`cx2cc.rs`: + +```rust +pub(crate) fn is_count_tokens_intercept_supported( + is_claude_count_tokens: bool, + capabilities: super::ProviderCapabilities, +) -> bool { + is_claude_count_tokens && capabilities.supports_count_tokens_local_intercept() +} +``` + +`gemini_oauth.rs`: + +```rust +pub(crate) fn is_gemini_oauth(capabilities: super::ProviderCapabilities) -> bool { + capabilities.gemini_oauth +} +``` + +`codex_chatgpt.rs`: + +```rust +pub(crate) fn is_codex_chatgpt_backend(capabilities: super::ProviderCapabilities) -> bool { + capabilities.codex_chatgpt_backend +} +``` + +`claude.rs`: + +```rust +pub(crate) fn is_anthropic_compatible(capabilities: super::ProviderCapabilities) -> bool { + capabilities.anthropic_compatible +} +``` + +- [ ] **Step 4: Export provider adapter module** + +Modify `src-tauri/src/gateway/proxy/mod.rs`: + +```rust +pub(crate) mod provider_adapters; +``` + +- [ ] **Step 5: Run provider adapter tests** + +Run: + +```bash +cd src-tauri && cargo test registry_identifies_cx2cc_bridge_capability --lib +cd src-tauri && cargo test registry_default_capabilities_are_plain_provider --lib +``` + +Expected: PASS. + +- [ ] **Step 6: Commit provider adapter facade** + +Run: + +```bash +git add src-tauri/src/gateway/proxy/provider_adapters src-tauri/src/gateway/proxy/mod.rs +git commit -m "refactor(gateway): add provider adapter capability facade" +``` + +Expected: commit adds facade only and does not change gateway behavior. + +### Task 8: Route CX2CC Count Tokens Through Provider Adapter Facade + +**Files:** +- Modify: `src-tauri/src/gateway/proxy/handler/middleware/cx2cc_count_tokens_interceptor.rs` +- Modify: `src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs` +- Test: existing tests in `cx2cc_count_tokens_interceptor.rs` + +- [ ] **Step 1: Write a facade-focused CX2CC test** + +In `cx2cc_count_tokens_interceptor.rs`, add: + +```rust +#[test] +fn cx2cc_count_tokens_uses_adapter_capability() { + let capabilities = crate::gateway::proxy::provider_adapters::ProviderCapabilities { + cx2cc_bridge: true, + ..Default::default() + }; + assert!( + crate::gateway::proxy::provider_adapters::cx2cc::is_count_tokens_intercept_supported( + true, + capabilities + ) + ); +} +``` + +- [ ] **Step 2: Run the test** + +Run: + +```bash +cd src-tauri && cargo test cx2cc_count_tokens_uses_adapter_capability --lib +``` + +Expected: PASS if Task 7 facade exists. If it fails, fix facade exports before changing behavior. + +- [ ] **Step 3: Add provider-to-capabilities conversion** + +In `provider_adapters/cx2cc.rs`, add: + +```rust +pub(crate) fn capabilities_for_provider( + provider: &crate::providers::ProviderForGateway, +) -> super::ProviderCapabilities { + super::ProviderCapabilities { + cx2cc_bridge: provider.is_cx2cc_bridge(), + ..Default::default() + } +} +``` + +- [ ] **Step 4: Use facade in interceptor** + +Replace `should_intercept_cx2cc_count_tokens` body with: + +```rust +pub(in crate::gateway::proxy::handler) fn should_intercept_cx2cc_count_tokens( + is_claude_count_tokens: bool, + providers: &[providers::ProviderForGateway], +) -> bool { + providers.first().is_some_and(|provider| { + let capabilities = + crate::gateway::proxy::provider_adapters::cx2cc::capabilities_for_provider(provider); + crate::gateway::proxy::provider_adapters::cx2cc::is_count_tokens_intercept_supported( + is_claude_count_tokens, + capabilities, + ) + }) +} +``` + +- [ ] **Step 5: Run existing CX2CC tests** + +Run: + +```bash +cd src-tauri && cargo test intercepts_count_tokens_only_when_first_provider_is_cx2cc --lib +cd src-tauri && cargo test cx2cc_count_tokens_response_body_is_positive --lib +cd src-tauri && cargo test cx2cc_count_tokens_response_sets_intercept_headers --lib +``` + +Expected: all pass with identical behavior. + +- [ ] **Step 6: Commit CX2CC facade routing** + +Run: + +```bash +git add src-tauri/src/gateway/proxy/handler/middleware/cx2cc_count_tokens_interceptor.rs src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs +git commit -m "refactor(gateway): route cx2cc count tokens through provider adapter" +``` + +Expected: commit routes one provider-specific behavior through the adapter facade. + +## Integration and Documentation + +### Task 9: Add Compatibility Documentation for 0.62 Internal Refactor + +**Files:** +- Modify: `docs/plugins/architecture/README.md` +- Modify: `docs/plugins/architecture/audit.md` +- Modify: `docs/plugins/reference/compatibility.md` +- Modify: `scripts/check-plugin-system-docs.mjs` + +- [ ] **Step 1: Write docs checker assertion** + +In `scripts/check-plugin-system-docs.mjs`, add required phrases for `docs/plugins/reference/compatibility.md`: + +```javascript +const compatibility = readText("docs/plugins/reference/compatibility.md"); +for (const phrase of [ + "Plugin API v1 remains externally compatible in 0.62", + "0.62 does not add public provider plugin APIs", + "0.62 keeps third-party JavaScript and WebView plugin execution unsupported", +]) { + if (!compatibility.includes(phrase)) { + failures.push(`docs/plugins/reference/compatibility.md: missing "${phrase}"`); + } +} +``` + +- [ ] **Step 2: Run docs checker and verify failure** + +Run: + +```bash +pnpm check:plugin-system-docs +``` + +Expected: FAIL because compatibility docs do not yet include the new 0.62 statements. + +- [ ] **Step 3: Update compatibility docs** + +Add this section to `docs/plugins/reference/compatibility.md`: + +```markdown +## 0.62 Internal Platform Kernel + +Plugin API v1 remains externally compatible in 0.62. The release reorganizes host internals around contract metadata, hook descriptors, runtime policy, runtime cache lifecycle, and provider adapter facades. + +0.62 does not add public provider plugin APIs. Provider adapter work is host-internal and exists to reduce gateway/provider maintenance cost before any future public API is considered. + +0.62 keeps third-party JavaScript and WebView plugin execution unsupported. Community plugins continue to use `declarativeRules`; WASM remains controlled by host policy. +``` + +- [ ] **Step 4: Update architecture audit** + +Add a short 0.62 decision note to `docs/plugins/architecture/audit.md`: + +```markdown +## 0.62 Platform Kernel Decision + +0.62 keeps Plugin API v1 externally compatible and focuses on internal platform boundaries. Contract metadata becomes the source for drift checks; hook behavior is routed through internal descriptors; runtime dispatch is separated from pipeline orchestration; provider-specific behavior starts moving behind provider adapter facades. +``` + +- [ ] **Step 5: Run docs checks** + +Run: + +```bash +pnpm check:plugin-system-docs +pnpm check:plugin-api-contract +``` + +Expected: both pass. + +- [ ] **Step 6: Commit docs compatibility update** + +Run: + +```bash +git add docs/plugins/architecture/README.md docs/plugins/architecture/audit.md docs/plugins/reference/compatibility.md scripts/check-plugin-system-docs.mjs +git commit -m "docs: document 0.62 plugin platform compatibility" +``` + +Expected: commit contains docs and checker updates only. + +### Task 10: Full Verification Gate + +**Files:** +- No source changes unless a verification failure requires a focused fix + +- [ ] **Step 1: Run frontend and plugin checks** + +Run: + +```bash +pnpm lint +pnpm typecheck +pnpm check:no-instant-now-sub +pnpm check:plugin-api-contract +pnpm check:plugin-system-docs +pnpm check:plugin-system-completion +pnpm plugin-sdk:typecheck +pnpm --filter @aio-coding-hub/plugin-sdk test +pnpm create-aio-plugin:test +pnpm plugin-wasm-sdk:test +``` + +Expected: all pass. + +- [ ] **Step 2: Run Rust checks** + +Run: + +```bash +pnpm tauri:fmt +pnpm tauri:check +cd src-tauri && cargo test plugin --lib +cd src-tauri && cargo test provider --lib +cd src-tauri && cargo test gateway --lib +``` + +Expected: all pass. + +- [ ] **Step 3: Run focused gateway behavior tests** + +Run: + +```bash +cd src-tauri && cargo test gateway_plugin_response_pipeline_applies_body_and_header_changes --lib +cd src-tauri && cargo test gateway_plugin_stream_pipeline_applies_chunk_changes --lib +cd src-tauri && cargo test intercepts_count_tokens_only_when_first_provider_is_cx2cc --lib +cd src-tauri && cargo test runtime_executor_returns_clear_error_for_policy_disabled_wasm --lib +``` + +Expected: all pass, proving Plugin API v1 and first provider adapter facade behavior remain compatible. + +- [ ] **Step 4: Inspect final diff** + +Run: + +```bash +git status --short +git log --oneline -10 +``` + +Expected: worktree clean after all task commits; recent commits correspond to this plan's task commits. + +- [ ] **Step 5: Prepare completion summary** + +Write a short summary for the user containing: + +```text +- Contract checker now validates structured Plugin API v1 metadata. +- Rust hook metadata and HookRegistry centralize hook descriptors. +- Mutation enforcement uses descriptors while keeping Plugin API v1 behavior. +- Runtime policy/cache boundaries are separated from gateway pipeline orchestration. +- CX2CC count_tokens is the first provider-specific behavior routed through provider adapter facade. +- Verification commands run and their pass/fail status. +``` + +Expected: user can see what changed, what was verified, and what remains internal-only. + +## Plan Self-Review + +- Spec coverage: Contract, Hook Registry, Runtime, Provider Adapter, frontend/backend boundary, non-goals, behavior compatibility, tests, and performance concerns are represented by Tasks 0-10. +- Placeholder scan: no task uses open-ended instructions; each implementation task has concrete files, snippets, commands, and expected outcomes. +- Type consistency: names introduced in earlier tasks are reused consistently: `HookContract`, `HookRegistry`, `HookDescriptor`, `RuntimePolicy`, `PluginRuntimeManager`, `ProviderCapabilities`. From ec4d28453f1a721ce827416a731421061cbb8287 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Sun, 21 Jun 2026 22:38:44 +0800 Subject: [PATCH 003/244] test: harden plugin api contract checker --- docs/plugins/plugin-api-v1-contract.json | 30 +++ scripts/check-plugin-api-contract.mjs | 46 ++++ .../check-plugin-api-contract.selftest.mjs | 239 ++++++++++++++---- 3 files changed, 272 insertions(+), 43 deletions(-) diff --git a/docs/plugins/plugin-api-v1-contract.json b/docs/plugins/plugin-api-v1-contract.json index 0ec21e10..6c2501a4 100644 --- a/docs/plugins/plugin-api-v1-contract.json +++ b/docs/plugins/plugin-api-v1-contract.json @@ -48,6 +48,11 @@ "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", @@ -70,6 +75,11 @@ }, "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.meta.read", "request.header.read", @@ -92,6 +102,11 @@ }, "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"], "mutationFields": ["streamChunk"], @@ -99,6 +114,11 @@ }, "gateway.response.after": { "phase": "after a complete non-streaming upstream response body is available", + "kind": "response", + "status": "active", + "defaultFailurePolicy": "fail-open", + "timeoutMs": 150, + "reservedHeaderPolicy": "block-gateway-owned", "readPermissions": ["response.header.read", "response.body.read"], "writePermissions": ["response.header.write", "response.body.write"], "mutationFields": ["headers", "responseBody"], @@ -111,6 +131,11 @@ }, "gateway.error": { "phase": "after gateway error response materialization and before it is sent", + "kind": "response", + "status": "active", + "defaultFailurePolicy": "fail-open", + "timeoutMs": 150, + "reservedHeaderPolicy": "block-gateway-owned", "readPermissions": ["response.header.read", "response.body.read"], "writePermissions": ["response.header.write", "response.body.write"], "mutationFields": ["headers", "responseBody"], @@ -123,6 +148,11 @@ }, "log.beforePersist": { "phase": "before gateway request log persistence", + "kind": "log", + "status": "active", + "defaultFailurePolicy": "fail-open", + "timeoutMs": 150, + "reservedHeaderPolicy": "block-gateway-owned", "readPermissions": ["log.redact"], "writePermissions": ["log.redact"], "mutationFields": ["logMessage"], diff --git a/scripts/check-plugin-api-contract.mjs b/scripts/check-plugin-api-contract.mjs index d1429c70..9be8e944 100644 --- a/scripts/check-plugin-api-contract.mjs +++ b/scripts/check-plugin-api-contract.mjs @@ -59,6 +59,28 @@ function requireRegex(path, text, regex, label) { } } +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 requireOneOf(path, value, allowed) { + if (!allowed.includes(value)) { + failures.push(`${path} must be one of ${allowed.join(", ")}`); + } +} + function runtimeTokens(contract) { return [...contract.communityRuntimes, ...contract.policyGatedRuntimes]; } @@ -75,6 +97,30 @@ const contractPath = "docs/plugins/plugin-api-v1-contract.json"; const contract = readJson(contractPath); if (contract) { + 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`); + } + requireArray(`hookMatrix.${hook}.readPermissions`, entry.readPermissions); + requireArray(`hookMatrix.${hook}.writePermissions`, entry.writePermissions); + requireArray(`hookMatrix.${hook}.mutationFields`, entry.mutationFields); + requireArray(`hookMatrix.${hook}.contextFields`, entry.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"); diff --git a/scripts/check-plugin-api-contract.selftest.mjs b/scripts/check-plugin-api-contract.selftest.mjs index 35f4e554..12339807 100644 --- a/scripts/check-plugin-api-contract.selftest.mjs +++ b/scripts/check-plugin-api-contract.selftest.mjs @@ -3,61 +3,214 @@ import { mkdirSync, 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 }); +function writeJson(root, path, value) { + writeFileSync(join(root, path), JSON.stringify(value, null, 2)); +} +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/plugin-wasm-sdk/src"), { recursive: true }); + mkdirSync(join(root, "packages/create-aio-plugin/src"), { recursive: true }); + mkdirSync(join(root, "src-tauri/src/domain"), { recursive: true }); + mkdirSync(join(root, "src-tauri/src/gateway/plugins"), { recursive: true }); + 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 writePassingScaffold(root) { + writeFileSync( + join(root, "packages/plugin-sdk/src/index.ts"), + [ + "gateway.request.afterBodyRead gateway.response.headers", + "request.body.read request.body.write network.fetch requestBody", + "declarativeRules wasm", + "export type ActiveGatewayHookName = 'gateway.request.afterBodyRead';", + "export type ReservedGatewayHookName = 'gateway.response.headers';", + "export type GatewayHookName = ActiveGatewayHookName | ReservedGatewayHookName;", + ].join("\n") + ); + writeFileSync( + join(root, "packages/create-aio-plugin/src/scaffold.ts"), + "declarativeRules wasm gateway.request.afterBodyRead request.body.read request.body.write" + ); + writeFileSync( + join(root, "src-tauri/src/domain/plugins.rs"), + [ + "gateway.request.afterBodyRead gateway.response.headers", + "request.body.read network.fetch declarativeRules wasm native privacyFilter", + "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\" }", + "pub fn is_reserved_permission(permission: &str) -> bool { permission == \"network.fetch\" }", + "PLUGIN_RESERVED_HOOK PLUGIN_RESERVED_PERMISSION", + ].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.response.headers request.body.read network.fetch" + ); + writeFileSync( + join(root, "docs/plugins/reference/hooks.md"), + "gateway.request.afterBodyRead gateway.response.headers" + ); + writeFileSync( + join(root, "docs/plugins/reference/permissions.md"), + "request.body.read network.fetch" + ); + 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(root, "packages/plugin-wasm-sdk/src/lib.rs"), + 'request_body #[serde(rename_all = "camelCase")]' + ); +} + +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: ["declarativeRules"], + policyGatedRuntimes: ["wasm"], + officialRuntimes: ["native:privacyFilter"], +}); 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 - ) -); -writeFileSync( - join(root, "packages/plugin-sdk/src/index.ts"), + join(reservedHookRoot, "packages/plugin-sdk/src/index.ts"), "gateway.request.afterBodyRead request.body.read declarativeRules" ); writeFileSync( - join(root, "packages/create-aio-plugin/src/scaffold.ts"), + join(reservedHookRoot, "packages/create-aio-plugin/src/scaffold.ts"), "declarativeRules gateway.request.afterBodyRead request.body.read" ); writeFileSync( - join(root, "src-tauri/src/domain/plugins.rs"), + join(reservedHookRoot, "src-tauri/src/domain/plugins.rs"), "gateway.request.afterBodyRead request.body.read declarativeRules" ); -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"); - -const result = spawnSync("node", ["scripts/check-plugin-api-contract.mjs"], { - cwd: process.cwd(), - env: { ...process.env, AIO_PLUGIN_CONTRACT_TEST_ROOT: root }, - encoding: "utf8", +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"), + "declarativeRules wasm 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: ["declarativeRules"], + policyGatedRuntimes: ["wasm"], + officialRuntimes: ["native:privacyFilter"], }); +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.mutationFields") +) { + throw new Error( + `expected hookMatrix metadata failure, got status ${missingHookMetadataResult.status}\n${missingHookMetadataResult.stderr}` + ); +} -if (result.status === 0 || !result.stderr.includes("gateway.response.headers")) { +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: ["declarativeRules"], + policyGatedRuntimes: ["wasm"], + officialRuntimes: ["native:privacyFilter"], +}); +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 structural contract failure, got status ${result.status}\n${result.stderr}` + `expected hookMatrix consistency failure, got status ${inconsistentHookMetadataResult.status}\n${inconsistentHookMetadataResult.stderr}` ); } From 395f69b3991f889896bf1a6fc6f644a33fd98e3c Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Sun, 21 Jun 2026 23:24:51 +0800 Subject: [PATCH 004/244] refactor(plugins): centralize rust plugin contract metadata --- scripts/check-plugin-api-contract.mjs | 41 +- .../check-plugin-api-contract.selftest.mjs | 15 +- src-tauri/src/domain/plugins.rs | 45 +- src-tauri/src/gateway/plugins/contract.rs | 423 ++++++++++++++++++ src-tauri/src/gateway/plugins/mod.rs | 1 + 5 files changed, 478 insertions(+), 47 deletions(-) create mode 100644 src-tauri/src/gateway/plugins/contract.rs diff --git a/scripts/check-plugin-api-contract.mjs b/scripts/check-plugin-api-contract.mjs index 9be8e944..cadea254 100644 --- a/scripts/check-plugin-api-contract.mjs +++ b/scripts/check-plugin-api-contract.mjs @@ -164,20 +164,49 @@ if (contract) { "default scaffold contract token" ); + const rustContract = readText("src-tauri/src/gateway/plugins/contract.rs"); + requireIncludes( + "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::is_reserved_permission", + "crate::gateway::plugins::contract::hook_contract", + ], + "contract metadata call-through" ); requireIncludesCaseInsensitive( "src-tauri/src/domain/plugins.rs", diff --git a/scripts/check-plugin-api-contract.selftest.mjs b/scripts/check-plugin-api-contract.selftest.mjs index 12339807..bc5f216d 100644 --- a/scripts/check-plugin-api-contract.selftest.mjs +++ b/scripts/check-plugin-api-contract.selftest.mjs @@ -45,13 +45,24 @@ function writePassingScaffold(root) { "declarativeRules wasm gateway.request.afterBodyRead request.body.read request.body.write" ); writeFileSync( - join(root, "src-tauri/src/domain/plugins.rs"), + join(root, "src-tauri/src/gateway/plugins/contract.rs"), [ "gateway.request.afterBodyRead gateway.response.headers", - "request.body.read network.fetch declarativeRules wasm native privacyFilter", + "request.body.read network.fetch", + ].join("\n") + ); + writeFileSync( + join(root, "src-tauri/src/domain/plugins.rs"), + [ + "declarativeRules wasm native privacyFilter", + "crate::gateway::plugins::contract::is_active_hook", + "crate::gateway::plugins::contract::is_reserved_hook", + "crate::gateway::plugins::contract::is_reserved_permission", + "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\" }", "pub fn is_reserved_permission(permission: &str) -> bool { permission == \"network.fetch\" }", + "fn permission_risk(permission: &str) { request.body.read; network.fetch; }", "PLUGIN_RESERVED_HOOK PLUGIN_RESERVED_PERMISSION", ].join("\n") ); diff --git a/src-tauri/src/domain/plugins.rs b/src-tauri/src/domain/plugins.rs index b8218b3a..e9094a7b 100644 --- a/src-tauri/src/domain/plugins.rs +++ b/src-tauri/src/domain/plugins.rs @@ -281,31 +281,15 @@ pub fn is_known_hook(hook: &str) -> bool { } 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" - ) + crate::gateway::plugins::contract::is_reserved_hook(hook) } 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_permission(permission) } fn validate_plugin_id(plugin_id: &str) -> Result<(), PluginValidationError> { @@ -443,26 +427,9 @@ fn validate_hook_permissions( } 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") - } - "stream.inspect" | "stream.modify" => hook_name == "gateway.response.chunk", - "log.redact" => hook_name == "log.beforePersist", - _ => false, - } + crate::gateway::plugins::contract::hook_contract(hook_name).is_some_and(|hook| { + hook.read_permissions.contains(&permission) || hook.write_permissions.contains(&permission) + }) } fn validate_permission_scope( diff --git a/src-tauri/src/gateway/plugins/contract.rs b/src-tauri/src/gateway/plugins/contract.rs new file mode 100644 index 00000000..b0ff0007 --- /dev/null +++ b/src-tauri/src/gateway/plugins/contract.rs @@ -0,0 +1,423 @@ +//! 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) mutation_fields: &'static [&'static str], + pub(crate) context_fields: &'static [&'static str], +} + +pub(crate) const DEFAULT_HOOK_TIMEOUT_MS: u64 = 150; +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"], + 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"], + 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"], + 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"], + 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"], + 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"], + 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: &[], + 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: &[], + 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: &[], + mutation_fields: &[], + context_fields: &[], + }, +]; + +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() +} + +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() + } + + #[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")); + } +} diff --git a/src-tauri/src/gateway/plugins/mod.rs b/src-tauri/src/gateway/plugins/mod.rs index b6532e29..dff86d1a 100644 --- a/src-tauri/src/gateway/plugins/mod.rs +++ b/src-tauri/src/gateway/plugins/mod.rs @@ -2,5 +2,6 @@ pub(crate) mod audit; pub(crate) mod context; +pub(crate) mod contract; pub(crate) mod permissions; pub(crate) mod pipeline; From 108e637d13ecb7a8b177f561ce6888c3da0bdc3e Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Sun, 21 Jun 2026 23:52:08 +0800 Subject: [PATCH 005/244] refactor(plugins): add internal hook registry --- src-tauri/src/gateway/plugins/context.rs | 38 ++++++++ src-tauri/src/gateway/plugins/mod.rs | 1 + src-tauri/src/gateway/plugins/registry.rs | 102 ++++++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 src-tauri/src/gateway/plugins/registry.rs diff --git a/src-tauri/src/gateway/plugins/context.rs b/src-tauri/src/gateway/plugins/context.rs index 92700680..8908d50d 100644 --- a/src-tauri/src/gateway/plugins/context.rs +++ b/src-tauri/src/gateway/plugins/context.rs @@ -33,6 +33,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, @@ -399,6 +417,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")); diff --git a/src-tauri/src/gateway/plugins/mod.rs b/src-tauri/src/gateway/plugins/mod.rs index dff86d1a..8ffd0843 100644 --- a/src-tauri/src/gateway/plugins/mod.rs +++ b/src-tauri/src/gateway/plugins/mod.rs @@ -5,3 +5,4 @@ pub(crate) mod context; pub(crate) mod contract; pub(crate) mod permissions; pub(crate) mod pipeline; +pub(crate) mod registry; diff --git a/src-tauri/src/gateway/plugins/registry.rs b/src-tauri/src/gateway/plugins/registry.rs new file mode 100644 index 00000000..417f44b7 --- /dev/null +++ b/src-tauri/src/gateway/plugins/registry.rs @@ -0,0 +1,102 @@ +//! Usage: Internal descriptors for gateway plugin hook metadata. + +#![allow(dead_code)] + +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::{ + HookKind, DEFAULT_FAILURE_POLICY, DEFAULT_HOOK_TIMEOUT_MS, + }; + + #[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")); + } +} From ff5206440360dad9d6c2525c9c723a75bab04919 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 00:08:30 +0800 Subject: [PATCH 006/244] refactor(plugins): enforce mutations through hook descriptors --- src-tauri/src/gateway/plugins/mod.rs | 1 + src-tauri/src/gateway/plugins/mutation.rs | 153 +++++++++++++++++++ src-tauri/src/gateway/plugins/permissions.rs | 96 +++--------- src-tauri/src/gateway/plugins/pipeline.rs | 24 +-- src-tauri/src/gateway/plugins/registry.rs | 2 - 5 files changed, 187 insertions(+), 89 deletions(-) create mode 100644 src-tauri/src/gateway/plugins/mutation.rs diff --git a/src-tauri/src/gateway/plugins/mod.rs b/src-tauri/src/gateway/plugins/mod.rs index 8ffd0843..c02f3434 100644 --- a/src-tauri/src/gateway/plugins/mod.rs +++ b/src-tauri/src/gateway/plugins/mod.rs @@ -3,6 +3,7 @@ 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..c75de1b5 --- /dev/null +++ b/src-tauri/src/gateway/plugins/mutation.rs @@ -0,0 +1,153 @@ +//! Usage: Descriptor-driven gateway plugin mutation enforcement. + +use super::context::{GatewayHookResult, GatewayPluginHookName}; +use super::permissions::GatewayPluginError; +use super::registry::HookDescriptor; + +pub(crate) fn enforce_descriptor_permissions( + descriptor: HookDescriptor, + permissions: &[String], + result: &GatewayHookResult, +) -> 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)?; + } + Ok(()) +} + +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"); + } +} diff --git a/src-tauri/src/gateway/plugins/permissions.rs b/src-tauri/src/gateway/plugins/permissions.rs index 318b800a..4920df26 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::mutation; use super::pipeline::GatewayPluginAuditEvent; +use super::registry::HookRegistry; use std::fmt; #[derive(Debug, Clone, PartialEq)] @@ -22,6 +24,10 @@ impl GatewayPluginError { #[cfg(test)] pub(crate) fn code(&self) -> &'static str { + self.code_for_logging() + } + + pub(crate) fn code_for_logging(&self) -> &'static str { self.code } @@ -46,7 +52,7 @@ impl GatewayPluginError { 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 +96,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..890698eb 100644 --- a/src-tauri/src/gateway/plugins/pipeline.rs +++ b/src-tauri/src/gateway/plugins/pipeline.rs @@ -5,7 +5,9 @@ use super::context::{ GatewayRequestHookInput, GatewayResponseHookInput, GatewayStreamHookInput, GatewayVisibleHookContext, }; -use super::permissions::{enforce_hook_result_permissions, GatewayPluginError}; +use super::permissions::{ + enforce_hook_result_permissions as enforce_descriptor_result_permissions, GatewayPluginError, +}; use crate::domain::plugins::{PluginDetail, PluginStatus}; use axum::body::Bytes; use axum::http::{HeaderMap, HeaderName, HeaderValue}; @@ -283,7 +285,7 @@ impl GatewayPluginPipeline { } }; - if let Err(err) = enforce_hook_result_permissions( + if let Err(err) = enforce_descriptor_result_permissions( input.hook_name, &plugin.granted_permissions, &result, @@ -403,7 +405,7 @@ impl GatewayPluginPipeline { } }; - if let Err(err) = enforce_hook_result_permissions( + if let Err(err) = enforce_descriptor_result_permissions( input.hook_name, &plugin.granted_permissions, &result, @@ -508,9 +510,11 @@ impl GatewayPluginPipeline { } }; - if let Err(err) = - enforce_hook_result_permissions(hook_name, &plugin.granted_permissions, &result) - { + if let Err(err) = enforce_descriptor_result_permissions( + hook_name, + &plugin.granted_permissions, + &result, + ) { 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 { @@ -606,9 +610,11 @@ impl GatewayPluginPipeline { } }; - if let Err(err) = - enforce_hook_result_permissions(hook_name, &plugin.granted_permissions, &result) - { + if let Err(err) = enforce_descriptor_result_permissions( + hook_name, + &plugin.granted_permissions, + &result, + ) { 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 { diff --git a/src-tauri/src/gateway/plugins/registry.rs b/src-tauri/src/gateway/plugins/registry.rs index 417f44b7..c27c08a9 100644 --- a/src-tauri/src/gateway/plugins/registry.rs +++ b/src-tauri/src/gateway/plugins/registry.rs @@ -1,7 +1,5 @@ //! Usage: Internal descriptors for gateway plugin hook metadata. -#![allow(dead_code)] - use super::context::GatewayPluginHookName; use super::contract::{hook_contract, HookKind}; From 61e0886ccc44b0f792f1e60fbe356742b2cb3d64 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 00:31:54 +0800 Subject: [PATCH 007/244] refactor(plugins): introduce runtime manager policy facade --- src-tauri/src/app/plugins/mod.rs | 2 + src-tauri/src/app/plugins/runtime_executor.rs | 56 +++++--- src-tauri/src/app/plugins/runtime_manager.rs | 125 ++++++++++++++++++ src-tauri/src/app/plugins/runtime_policy.rs | 7 + 4 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 src-tauri/src/app/plugins/runtime_manager.rs create mode 100644 src-tauri/src/app/plugins/runtime_policy.rs diff --git a/src-tauri/src/app/plugins/mod.rs b/src-tauri/src/app/plugins/mod.rs index 31e6097c..c7eb10e6 100644 --- a/src-tauri/src/app/plugins/mod.rs +++ b/src-tauri/src/app/plugins/mod.rs @@ -6,4 +6,6 @@ pub(crate) mod privacy_filter; pub(crate) mod process_runtime; pub(crate) mod rule_runtime; pub(crate) mod runtime_executor; +pub(crate) mod runtime_manager; +pub(crate) mod runtime_policy; pub(crate) mod wasm_runtime; diff --git a/src-tauri/src/app/plugins/runtime_executor.rs b/src-tauri/src/app/plugins/runtime_executor.rs index 090b7506..9337a3eb 100644 --- a/src-tauri/src/app/plugins/runtime_executor.rs +++ b/src-tauri/src/app/plugins/runtime_executor.rs @@ -1,6 +1,8 @@ //! Usage: Runtime dispatch for gateway plugin execution. -use crate::domain::plugins::{PluginDetail, PluginRuntime}; +use crate::app::plugins::runtime_manager::{PluginRuntimeManager, RuntimeDispatch}; +use crate::app::plugins::runtime_policy::RuntimePolicy; +use crate::domain::plugins::PluginDetail; use crate::gateway::plugins::context::{GatewayHookResult, GatewayVisibleHookContext}; use crate::gateway::plugins::permissions::GatewayPluginError; use crate::gateway::plugins::pipeline::{GatewayHookFuture, GatewayPluginExecutor}; @@ -31,28 +33,26 @@ impl RuntimeGatewayPluginExecutor { plugin: &PluginDetail, context: GatewayVisibleHookContext, ) -> Result { - match &plugin.manifest.runtime { - PluginRuntime::DeclarativeRules { .. } => self + let manager = PluginRuntimeManager::new(RuntimePolicy { + wasm_enabled: self.policy.wasm_enabled, + process_enabled: false, + }); + + match manager.runtime_dispatch(&plugin.manifest.runtime)? { + RuntimeDispatch::DeclarativeRules => self .rule_runtime .execute_declarative_rules_plugin(plugin, context), - PluginRuntime::Native { engine } - if plugin.summary.plugin_id == "official.privacy-filter" - && engine == "privacyFilter" => + RuntimeDispatch::NativePrivacyFilter + if plugin.summary.plugin_id == "official.privacy-filter" => { self.rule_runtime .execute_official_privacy_filter_plugin(plugin, context) } - PluginRuntime::Native { engine } => Err(GatewayPluginError::new( + RuntimeDispatch::NativePrivacyFilter => Err(GatewayPluginError::new( "PLUGIN_UNSUPPORTED_RUNTIME", - format!("unsupported native plugin runtime engine: {engine}"), + "unsupported native plugin runtime engine: privacyFilter", )), - PluginRuntime::Wasm { .. } if !self.policy.wasm_enabled => { - Err(GatewayPluginError::new( - "PLUGIN_RUNTIME_DISABLED", - "wasm runtime execution is disabled by host policy", - )) - } - PluginRuntime::Wasm { .. } => Err(GatewayPluginError::new( + RuntimeDispatch::WasmNotWired => Err(GatewayPluginError::new( "PLUGIN_WASM_NOT_WIRED", "wasm runtime policy is enabled but gateway execution is not wired in this release", )), @@ -164,6 +164,32 @@ mod tests { assert_eq!(result.action, GatewayHookAction::Continue); } + #[test] + fn runtime_executor_rejects_non_official_privacy_filter_native_runtime() { + let executor = RuntimeGatewayPluginExecutor::for_tests(RuntimeExecutionPolicy { + wasm_enabled: false, + }); + let plugin = plugin_detail( + "example.privacy-filter", + PluginRuntime::Native { + engine: "privacyFilter".to_string(), + }, + "native:privacyFilter".to_string(), + None, + ); + let context = hook_context("gateway.request.afterBodyRead", "trace-native"); + + let err = executor + .execute_plugin_sync(&plugin, context) + .expect_err("non-official native privacy filter should be rejected"); + + assert_eq!(err.code(), "PLUGIN_UNSUPPORTED_RUNTIME"); + assert_eq!( + err.to_string(), + "PLUGIN_UNSUPPORTED_RUNTIME: unsupported native plugin runtime engine: privacyFilter" + ); + } + fn executor() -> RuntimeGatewayPluginExecutor { RuntimeGatewayPluginExecutor::for_tests(RuntimeExecutionPolicy { wasm_enabled: false, 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..f26ed86b --- /dev/null +++ b/src-tauri/src/app/plugins/runtime_manager.rs @@ -0,0 +1,125 @@ +//! Usage: Runtime policy facade for plugin runtime execution. + +use crate::app::plugins::runtime_policy::RuntimePolicy; +use crate::domain::plugins::PluginRuntime; +use crate::gateway::plugins::permissions::GatewayPluginError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RuntimeDispatch { + DeclarativeRules, + NativePrivacyFilter, + WasmNotWired, +} + +pub(crate) struct PluginRuntimeManager { + policy: RuntimePolicy, +} + +impl PluginRuntimeManager { + pub(crate) fn new(policy: RuntimePolicy) -> Self { + Self { policy } + } + + #[cfg(test)] + pub(crate) fn for_tests(policy: RuntimePolicy) -> Self { + Self::new(policy) + } + + pub(crate) fn validate_runtime_policy( + &self, + runtime: &PluginRuntime, + ) -> Result<(), GatewayPluginError> { + if matches!(runtime, PluginRuntime::Wasm { .. }) && !self.policy.wasm_enabled { + return Err(GatewayPluginError::new( + "PLUGIN_RUNTIME_DISABLED", + "wasm runtime execution is disabled by host policy", + )); + } + Ok(()) + } + + pub(crate) fn runtime_dispatch( + &self, + runtime: &PluginRuntime, + ) -> Result { + self.validate_runtime_policy(runtime)?; + // Reserved for future process runtime policy. Plugin API v1 has no + // process runtime variant, so this flag is intentionally not decisive yet. + let _process_enabled = self.policy.process_enabled; + + match runtime { + PluginRuntime::DeclarativeRules { .. } => Ok(RuntimeDispatch::DeclarativeRules), + PluginRuntime::Native { engine } if engine == "privacyFilter" => { + Ok(RuntimeDispatch::NativePrivacyFilter) + } + PluginRuntime::Native { engine } => Err(GatewayPluginError::new( + "PLUGIN_UNSUPPORTED_RUNTIME", + format!("unsupported native plugin runtime engine: {engine}"), + )), + PluginRuntime::Wasm { .. } => Ok(RuntimeDispatch::WasmNotWired), + } + } +} + +#[cfg(test)] +mod tests { + use super::{PluginRuntimeManager, RuntimeDispatch}; + use crate::app::plugins::runtime_policy::RuntimePolicy; + use crate::domain::plugins::PluginRuntime; + + #[test] + fn runtime_manager_rejects_wasm_when_policy_disabled() { + let manager = PluginRuntimeManager::for_tests(RuntimePolicy::default()); + let runtime = PluginRuntime::Wasm { + abi_version: "1.0.0".to_string(), + memory_limit_bytes: Some(16 * 1024 * 1024), + }; + + let err = manager + .validate_runtime_policy(&runtime) + .expect_err("wasm should be disabled by default policy"); + + assert_eq!(err.code(), "PLUGIN_RUNTIME_DISABLED"); + assert_eq!( + err.to_string(), + "PLUGIN_RUNTIME_DISABLED: wasm runtime execution is disabled by host policy" + ); + } + + #[test] + fn runtime_manager_allows_declarative_rules_policy() { + let manager = PluginRuntimeManager::for_tests(RuntimePolicy::default()); + let runtime = PluginRuntime::DeclarativeRules { + rules: vec!["rules/main.json".to_string()], + }; + + manager + .validate_runtime_policy(&runtime) + .expect("declarative rules should be allowed by host policy"); + assert_eq!( + manager + .runtime_dispatch(&runtime) + .expect("declarative rules should resolve"), + RuntimeDispatch::DeclarativeRules + ); + } + + #[test] + fn runtime_manager_returns_wasm_not_wired_decision_when_policy_enabled() { + let manager = PluginRuntimeManager::for_tests(RuntimePolicy { + wasm_enabled: true, + process_enabled: false, + }); + let runtime = PluginRuntime::Wasm { + abi_version: "1.0.0".to_string(), + memory_limit_bytes: Some(16 * 1024 * 1024), + }; + + assert_eq!( + manager + .runtime_dispatch(&runtime) + .expect("enabled wasm policy should reach dispatch decision"), + RuntimeDispatch::WasmNotWired + ); + } +} diff --git a/src-tauri/src/app/plugins/runtime_policy.rs b/src-tauri/src/app/plugins/runtime_policy.rs new file mode 100644 index 00000000..1090e79f --- /dev/null +++ b/src-tauri/src/app/plugins/runtime_policy.rs @@ -0,0 +1,7 @@ +//! Usage: Host policy flags for plugin runtime execution. + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct RuntimePolicy { + pub(crate) wasm_enabled: bool, + pub(crate) process_enabled: bool, +} From 05e685a94bc3c7b7e77777161386945c07c4543d Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 00:51:18 +0800 Subject: [PATCH 008/244] refactor(plugins): share runtime cache key helpers --- src-tauri/src/app/plugins/mod.rs | 1 + src-tauri/src/app/plugins/rule_runtime.rs | 25 +++++--- src-tauri/src/app/plugins/runtime_cache.rs | 67 ++++++++++++++++++++++ 3 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 src-tauri/src/app/plugins/runtime_cache.rs diff --git a/src-tauri/src/app/plugins/mod.rs b/src-tauri/src/app/plugins/mod.rs index c7eb10e6..f2d12ced 100644 --- a/src-tauri/src/app/plugins/mod.rs +++ b/src-tauri/src/app/plugins/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod official_assets; pub(crate) mod privacy_filter; pub(crate) mod process_runtime; pub(crate) mod rule_runtime; +pub(crate) mod runtime_cache; pub(crate) mod runtime_executor; pub(crate) mod runtime_manager; pub(crate) mod runtime_policy; diff --git a/src-tauri/src/app/plugins/rule_runtime.rs b/src-tauri/src/app/plugins/rule_runtime.rs index 308bb4ba..1cc3df3c 100644 --- a/src-tauri/src/app/plugins/rule_runtime.rs +++ b/src-tauri/src/app/plugins/rule_runtime.rs @@ -1,6 +1,7 @@ //! Usage: Declarative, no-code plugin rule runtime. use super::privacy_filter::{PrivacyFilter, PrivacyFilterError, PrivacyFilterOptions}; +use super::runtime_cache::{runtime_cache_key, RuntimeCacheKeyInput}; use crate::gateway::plugins::context::{ GatewayHookAction, GatewayHookResult, GatewayVisibleHookContext, }; @@ -311,15 +312,18 @@ fn rule_runtime_cache_key(plugin: &PluginDetail) -> String { .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 { + let runtime_key = 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 - ) + runtime_cache_key(RuntimeCacheKeyInput { + plugin_id: plugin.summary.plugin_id.as_str(), + version, + installed_dir, + updated_at, + runtime_key: runtime_key.as_str(), + }) } fn privacy_filter_cache_key(plugin: &PluginDetail) -> String { @@ -330,10 +334,13 @@ fn privacy_filter_cache_key(plugin: &PluginDetail) -> String { .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 - ) + runtime_cache_key(RuntimeCacheKeyInput { + plugin_id: plugin.summary.plugin_id.as_str(), + version, + installed_dir, + updated_at, + runtime_key: "native:privacyFilter", + }) } #[cfg(test)] 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..b61d50c4 --- /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:privacyFilter", + }); + + 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:privacyFilter")); + } +} From 773992b2b0d5fa70cdd2e46fc36ecbafd4909519 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 01:06:59 +0800 Subject: [PATCH 009/244] refactor(gateway): add provider adapter capability facade --- src-tauri/src/gateway/proxy/mod.rs | 1 + .../gateway/proxy/provider_adapters/claude.rs | 5 ++ .../proxy/provider_adapters/codex_chatgpt.rs | 5 ++ .../gateway/proxy/provider_adapters/cx2cc.rs | 8 +++ .../proxy/provider_adapters/gemini_oauth.rs | 5 ++ .../gateway/proxy/provider_adapters/mod.rs | 65 +++++++++++++++++++ 6 files changed, 89 insertions(+) create mode 100644 src-tauri/src/gateway/proxy/provider_adapters/claude.rs create mode 100644 src-tauri/src/gateway/proxy/provider_adapters/codex_chatgpt.rs create mode 100644 src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs create mode 100644 src-tauri/src/gateway/proxy/provider_adapters/gemini_oauth.rs create mode 100644 src-tauri/src/gateway/proxy/provider_adapters/mod.rs diff --git a/src-tauri/src/gateway/proxy/mod.rs b/src-tauri/src/gateway/proxy/mod.rs index e3a13e7a..168d6c61 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; 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..4e91896c --- /dev/null +++ b/src-tauri/src/gateway/proxy/provider_adapters/claude.rs @@ -0,0 +1,5 @@ +use super::ProviderCapabilities; + +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..a34c5430 --- /dev/null +++ b/src-tauri/src/gateway/proxy/provider_adapters/codex_chatgpt.rs @@ -0,0 +1,5 @@ +use super::ProviderCapabilities; + +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..8e636418 --- /dev/null +++ b/src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs @@ -0,0 +1,8 @@ +use super::ProviderCapabilities; + +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..9c32252f --- /dev/null +++ b/src-tauri/src/gateway/proxy/provider_adapters/gemini_oauth.rs @@ -0,0 +1,5 @@ +use super::ProviderCapabilities; + +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..0a750c88 --- /dev/null +++ b/src-tauri/src/gateway/proxy/provider_adapters/mod.rs @@ -0,0 +1,65 @@ +#![allow(dead_code)] + +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()); + } +} From d7d54ed1128000f1fc628c0b1a81fa614c113759 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 01:17:06 +0800 Subject: [PATCH 010/244] refactor(gateway): route cx2cc count tokens through provider adapter --- .../cx2cc_count_tokens_interceptor.rs | 31 ++++++++++++++++--- .../gateway/proxy/provider_adapters/cx2cc.rs | 9 ++++++ 2 files changed, 36 insertions(+), 4 deletions(-) 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..7a690735 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 { @@ -123,6 +127,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/provider_adapters/cx2cc.rs b/src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs index 8e636418..d4114126 100644 --- a/src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs +++ b/src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs @@ -1,5 +1,14 @@ 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, From 3188865cad061ca815da1a4fcd38ce9dee32ac02 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 01:29:46 +0800 Subject: [PATCH 011/244] docs: document 0.62 plugin platform compatibility --- docs/plugins/architecture/README.md | 2 ++ docs/plugins/architecture/audit.md | 4 ++++ docs/plugins/reference/compatibility.md | 8 ++++++++ scripts/check-plugin-system-docs.mjs | 10 +++++++++- 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/plugins/architecture/README.md b/docs/plugins/architecture/README.md index 1b8e4909..146ecd0b 100644 --- a/docs/plugins/architecture/README.md +++ b/docs/plugins/architecture/README.md @@ -4,3 +4,5 @@ - [安全与隔离](./security.md):最小权限、运行时隔离、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..280748bb 100644 --- a/docs/plugins/architecture/audit.md +++ b/docs/plugins/architecture/audit.md @@ -95,3 +95,7 @@ Bundled official plugin: - Plugin refresh 时会清理 runtime caches。 - Plugin hot-path performance smoke tests 是 release readiness 的一部分。 - `create-aio-plugin replay` 与受支持的 declarative rule subset 保持一致。 + +## 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 后面。 diff --git a/docs/plugins/reference/compatibility.md b/docs/plugins/reference/compatibility.md index 134f2d10..477b93b6 100644 --- a/docs/plugins/reference/compatibility.md +++ b/docs/plugins/reference/compatibility.md @@ -17,3 +17,11 @@ WASM 插件还需要声明 WASM ABI 版本: ``` 宿主会拒绝不支持的主版本。未来插件 API 变更必须保持向后兼容;无法兼容时,需要提升主版本并让旧插件继续按旧契约运行或被明确标记为不兼容。 + +## 0.62 Internal Platform Kernel + +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,需要另行设计版本化契约。 + +0.62 keeps third-party JavaScript and WebView plugin execution unsupported. 社区插件继续使用 `declarativeRules`;WASM 仍受宿主策略控制,未启用时安装或执行会被拒绝。 diff --git a/scripts/check-plugin-system-docs.mjs b/scripts/check-plugin-system-docs.mjs index d158303c..ed46a47f 100644 --- a/scripts/check-plugin-system-docs.mjs +++ b/scripts/check-plugin-system-docs.mjs @@ -199,7 +199,15 @@ const requiredDocs = [ }, { path: "docs/plugins/reference/compatibility.md", - phrases: ["SemVer", "pluginApi", "platforms", "WASM ABI"], + phrases: [ + "SemVer", + "pluginApi", + "platforms", + "WASM ABI", + "Plugin API v1 remains externally compatible in 0.62", + "0.62 does not add public provider plugin APIs", + "0.62 keeps third-party JavaScript and WebView plugin execution unsupported", + ], }, ]; From cdb9d87e77fd4e4a1d80e79003eeef7cbb22dfa8 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 09:53:06 +0800 Subject: [PATCH 012/244] fix: address plugin platform review findings --- docs/plugins/plugin-api-v1-contract.json | 12 + ...-coding-hub-0-62-plugin-platform-kernel.md | 1603 ----------------- scripts/check-plugin-api-contract.mjs | 22 +- .../check-plugin-api-contract.selftest.mjs | 3 + src-tauri/src/app/plugins/runtime_executor.rs | 15 +- src-tauri/src/app/plugins/runtime_manager.rs | 27 +- src-tauri/src/domain/plugins.rs | 46 +- src-tauri/src/gateway/plugins/contract.rs | 80 + .../gateway/proxy/provider_adapters/claude.rs | 1 + .../proxy/provider_adapters/codex_chatgpt.rs | 1 + .../proxy/provider_adapters/gemini_oauth.rs | 1 + .../gateway/proxy/provider_adapters/mod.rs | 2 - 12 files changed, 170 insertions(+), 1643 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel.md diff --git a/docs/plugins/plugin-api-v1-contract.json b/docs/plugins/plugin-api-v1-contract.json index 6c2501a4..a7cdf64e 100644 --- a/docs/plugins/plugin-api-v1-contract.json +++ b/docs/plugins/plugin-api-v1-contract.json @@ -60,6 +60,9 @@ "request.body.read" ], "writePermissions": ["request.header.write", "request.body.write"], + "permissionDependencies": { + "request.body.write": ["request.body.read"] + }, "mutationFields": ["headers", "requestBody"], "contextFields": [ "traceId", @@ -87,6 +90,7 @@ "request.body.read" ], "writePermissions": ["request.header.write", "request.body.write"], + "permissionDependencies": {}, "mutationFields": ["headers", "requestBody"], "contextFields": [ "traceId", @@ -109,6 +113,9 @@ "reservedHeaderPolicy": "block-gateway-owned", "readPermissions": ["stream.inspect"], "writePermissions": ["stream.modify"], + "permissionDependencies": { + "stream.modify": ["stream.inspect"] + }, "mutationFields": ["streamChunk"], "contextFields": ["traceId", "stream.sequence", "stream.chunk"] }, @@ -121,6 +128,9 @@ "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", @@ -138,6 +148,7 @@ "reservedHeaderPolicy": "block-gateway-owned", "readPermissions": ["response.header.read", "response.body.read"], "writePermissions": ["response.header.write", "response.body.write"], + "permissionDependencies": {}, "mutationFields": ["headers", "responseBody"], "contextFields": [ "traceId", @@ -155,6 +166,7 @@ "reservedHeaderPolicy": "block-gateway-owned", "readPermissions": ["log.redact"], "writePermissions": ["log.redact"], + "permissionDependencies": {}, "mutationFields": ["logMessage"], "contextFields": ["traceId", "log.message"] } diff --git a/docs/superpowers/plans/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel.md b/docs/superpowers/plans/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel.md deleted file mode 100644 index 7a8c7e4c..00000000 --- a/docs/superpowers/plans/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel.md +++ /dev/null @@ -1,1603 +0,0 @@ -# aio-coding-hub 0.62.0 Plugin Platform Kernel Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Rework the plugin platform internals around a contract-driven hook, runtime, and provider-adapter kernel while preserving Plugin API v1 external behavior. - -**Architecture:** Keep `docs/plugins/plugin-api-v1-contract.json` as the canonical public contract, then derive or structurally check Rust, TypeScript, docs, scaffold, and runtime behavior against it. Introduce internal registries and facades in layers: contract metadata, hook descriptors, mutation/context enforcement, runtime manager, and provider adapter facade. No new public plugin API is added in 0.62.0. - -**Tech Stack:** Rust/Tauri 2/Axum/SQLite/Specta, TypeScript/React/Vite/Vitest, Node.js contract scripts, Cargo tests, pnpm workspace tooling. - ---- - -## Scope Note - -The design covers Contract, Hook Registry, Runtime, and Provider Adapter layers. These are not independent product features; they are dependent platform layers. This plan keeps them in one master implementation plan so each task can validate external Plugin API v1 compatibility before the next layer builds on it. - -## File Responsibility Map - -- `docs/plugins/plugin-api-v1-contract.json`: canonical Plugin API v1 contract. -- `scripts/check-plugin-api-contract.mjs`: structural drift checker for contract, Rust, SDK, docs, scaffold, and WASM SDK. -- `scripts/check-plugin-api-contract.selftest.mjs`: checker regression tests using temporary fixture repositories. -- `src-tauri/src/gateway/plugins/contract.rs`: Rust view of hook, permission, runtime, timeout, and mutation metadata. -- `src-tauri/src/gateway/plugins/registry.rs`: internal `HookRegistry` and `HookDescriptor` lookup APIs. -- `src-tauri/src/gateway/plugins/mutation.rs`: hook result permission enforcement and mutation application helpers. -- `src-tauri/src/gateway/plugins/context.rs`: visible hook context types and context builders. -- `src-tauri/src/gateway/plugins/pipeline.rs`: hook ordering, timeout, circuit, audit, and executor orchestration. -- `src-tauri/src/app/plugins/runtime_manager.rs`: runtime dispatch facade used by gateway plugin pipeline. -- `src-tauri/src/app/plugins/runtime_policy.rs`: host runtime policy for WASM/process/native runtime availability. -- `src-tauri/src/app/plugins/runtime_cache.rs`: cache key and cache retention helpers shared by runtime implementations. -- `src-tauri/src/app/plugins/runtimes/declarative_rules.rs`: declarative rules runtime module after extraction from current rule runtime. -- `src-tauri/src/app/plugins/runtimes/official_privacy_filter.rs`: official native privacy filter module after extraction. -- `src-tauri/src/app/plugins/runtimes/wasm_policy.rs`: policy-gated WASM runtime entrypoint that returns stable disabled errors. -- `src-tauri/src/app/plugins/runtime_executor.rs`: temporary compatibility wrapper until call sites use `PluginRuntimeManager`. -- `src-tauri/src/gateway/proxy/provider_adapters/mod.rs`: provider adapter facade and registry. -- `src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs`: CX2CC adapter facade. -- `src-tauri/src/gateway/proxy/provider_adapters/gemini_oauth.rs`: Gemini OAuth adapter facade. -- `src-tauri/src/gateway/proxy/provider_adapters/codex_chatgpt.rs`: Codex ChatGPT adapter facade. -- `src-tauri/src/gateway/proxy/provider_adapters/claude.rs`: Claude model mapping and Claude-specific adapter facade. -- `src-tauri/src/gateway/proxy/handler/*`: gateway call sites that should call facades instead of adding new provider-specific branches. -- `packages/plugin-sdk/src/index.ts`: public TypeScript Plugin API v1 SDK, unchanged externally. -- `packages/create-aio-plugin/src/devtools.ts`: author tooling replay/pack behavior, unchanged externally. -- `docs/plugins/reference/*.md`: public docs, updated only to describe internal 0.62 compatibility guarantees where needed. - -## Baseline Verification - -### Task 0: Record Current State - -**Files:** -- Read: `package.json` -- Read: `docs/plugins/plugin-api-v1-contract.json` -- Read: `docs/superpowers/specs/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel-design.md` -- No source changes - -- [ ] **Step 1: Confirm a clean or understood worktree** - -Run: - -```bash -git status --short -``` - -Expected: either no output, or only files intentionally owned by this task. Do not revert unrelated user changes. - -- [ ] **Step 2: Run plugin contract checks** - -Run: - -```bash -pnpm check:plugin-api-contract -pnpm check:plugin-system-docs -pnpm check:plugin-system-completion -``` - -Expected: all pass before refactor work begins. If a command fails, record the failing command and exact output in the task journal before changing code. - -- [ ] **Step 3: Run SDK and author-tool tests** - -Run: - -```bash -pnpm plugin-sdk:typecheck -pnpm --filter @aio-coding-hub/plugin-sdk test -pnpm create-aio-plugin:test -pnpm plugin-wasm-sdk:test -``` - -Expected: all pass before refactor work begins. - -- [ ] **Step 4: Run Rust plugin/gateway/provider baseline tests** - -Run: - -```bash -cd src-tauri && cargo test plugin --lib -cd src-tauri && cargo test provider --lib -cd src-tauri && cargo test gateway --lib -``` - -Expected: all pass, or any pre-existing failures are documented with exact test names. - -- [ ] **Step 5: Commit baseline note only if files were changed** - -If no files changed, do not commit. If a local journal file was intentionally updated, run: - -```bash -git add -git commit -m "test: record plugin platform baseline" -``` - -Expected: either no commit is created, or exactly the journal file is committed. - -## Contract Layer - -### Task 1: Extend the Contract JSON Shape Without Changing Public API - -**Files:** -- Modify: `docs/plugins/plugin-api-v1-contract.json` -- Modify: `scripts/check-plugin-api-contract.mjs` -- Modify: `scripts/check-plugin-api-contract.selftest.mjs` -- Test: `scripts/check-plugin-api-contract.selftest.mjs` - -- [ ] **Step 1: Write a failing self-test for missing hook matrix fields** - -Modify `scripts/check-plugin-api-contract.selftest.mjs` so its temporary `plugin-api-v1-contract.json` includes an active hook entry missing `kind`, `status`, or `mutationFields`. Add this assertion near the existing spawn check: - -```javascript -if (result.status === 0 || !result.stderr.includes("hookMatrix.gateway.request.afterBodyRead.kind")) { - throw new Error(`expected hookMatrix kind failure, got status ${result.status}\n${result.stderr}`); -} -``` - -- [ ] **Step 2: Run the self-test and verify it fails** - -Run: - -```bash -node scripts/check-plugin-api-contract.selftest.mjs -``` - -Expected: FAIL because `scripts/check-plugin-api-contract.mjs` does not yet validate structured hook matrix metadata. - -- [ ] **Step 3: Add structured fields to the canonical contract** - -Update every `hookMatrix` entry in `docs/plugins/plugin-api-v1-contract.json` to include these exact fields: - -```json -{ - "kind": "request", - "status": "active", - "defaultFailurePolicy": "fail-open", - "timeoutMs": 150, - "reservedHeaderPolicy": "block-gateway-owned" -} -``` - -Use `kind` values `request`, `response`, `stream`, and `log`. Use `status: "active"` for active hooks. Keep the existing active/reserved hook arrays unchanged. - -- [ ] **Step 4: Implement structured checker helpers** - -In `scripts/check-plugin-api-contract.mjs`, add helpers: - -```javascript -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 requireOneOf(path, value, allowed) { - if (!allowed.includes(value)) { - failures.push(`${path} must be one of ${allowed.join(", ")}`); - } -} -``` - -Then validate each `contract.activeHooks` entry: - -```javascript -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"]); - requireArray(`hookMatrix.${hook}.readPermissions`, entry.readPermissions); - requireArray(`hookMatrix.${hook}.writePermissions`, entry.writePermissions); - requireArray(`hookMatrix.${hook}.mutationFields`, entry.mutationFields); - requireArray(`hookMatrix.${hook}.contextFields`, entry.contextFields); - if (entry.timeoutMs !== contract.defaultHookTimeoutMs) { - failures.push(`hookMatrix.${hook}.timeoutMs must equal defaultHookTimeoutMs`); - } -} -``` - -- [ ] **Step 5: Run the self-test and verify it passes** - -Run: - -```bash -node scripts/check-plugin-api-contract.selftest.mjs -``` - -Expected: PASS because the checker detects the intentionally incomplete fixture. - -- [ ] **Step 6: Run the real contract check** - -Run: - -```bash -pnpm check:plugin-api-contract -``` - -Expected: PASS against the real repository. - -- [ ] **Step 7: Commit contract checker hardening** - -Run: - -```bash -git add docs/plugins/plugin-api-v1-contract.json scripts/check-plugin-api-contract.mjs scripts/check-plugin-api-contract.selftest.mjs -git commit -m "test: harden plugin api contract checker" -``` - -Expected: commit contains only contract JSON and checker files. - -### Task 2: Add Rust Contract Metadata - -**Files:** -- Create: `src-tauri/src/gateway/plugins/contract.rs` -- Modify: `src-tauri/src/gateway/plugins/mod.rs` -- Modify: `src-tauri/src/domain/plugins.rs` -- Test: Rust unit tests in `src-tauri/src/gateway/plugins/contract.rs` - -- [ ] **Step 1: Write Rust metadata tests** - -Create `src-tauri/src/gateway/plugins/contract.rs` with tests first: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn active_hook_metadata_matches_plugin_api_v1() { - let ids: Vec<&'static str> = ACTIVE_HOOKS.iter().map(|hook| hook.id).collect(); - assert_eq!( - ids, - vec![ - "gateway.request.afterBodyRead", - "gateway.request.beforeSend", - "gateway.response.chunk", - "gateway.response.after", - "gateway.error", - "log.beforePersist", - ] - ); - } - - #[test] - fn reserved_hook_metadata_matches_plugin_api_v1() { - assert!(is_reserved_hook("gateway.request.received")); - assert!(is_reserved_hook("gateway.request.beforeProviderResolution")); - assert!(is_reserved_hook("gateway.response.headers")); - assert!(!is_reserved_hook("gateway.response.after")); - } - - #[test] - fn permission_metadata_marks_reserved_permissions() { - assert!(is_reserved_permission("network.fetch")); - assert!(is_reserved_permission("file.read")); - assert!(!is_reserved_permission("request.body.read")); - } -} -``` - -- [ ] **Step 2: Run the new Rust test and verify it fails to compile** - -Run: - -```bash -cd src-tauri && cargo test active_hook_metadata_matches_plugin_api_v1 --lib -``` - -Expected: FAIL because `ACTIVE_HOOKS`, `is_reserved_hook`, and `is_reserved_permission` are not implemented yet. - -- [ ] **Step 3: Implement Rust contract metadata** - -Add the implementation above the tests in `contract.rs`: - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum HookKind { - Request, - Response, - Stream, - Log, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum HookStatus { - Active, - Reserved, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct HookContract { - pub(crate) id: &'static str, - pub(crate) kind: HookKind, - pub(crate) status: HookStatus, - 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, -} - -pub(crate) const DEFAULT_HOOK_TIMEOUT_MS: u64 = 150; -pub(crate) const DEFAULT_FAILURE_POLICY: &str = "fail-open"; - -pub(crate) const ACTIVE_HOOKS: &[HookContract] = &[ - HookContract { - id: "gateway.request.afterBodyRead", - kind: HookKind::Request, - status: HookStatus::Active, - read_permissions: &[ - "request.meta.read", - "request.header.read", - "request.header.readSensitive", - "request.body.read", - ], - write_permissions: &["request.header.write", "request.body.write"], - mutation_fields: &["headers", "requestBody"], - timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, - default_failure_policy: DEFAULT_FAILURE_POLICY, - }, - HookContract { - id: "gateway.request.beforeSend", - kind: HookKind::Request, - status: HookStatus::Active, - read_permissions: &[ - "request.meta.read", - "request.header.read", - "request.header.readSensitive", - "request.body.read", - ], - write_permissions: &["request.header.write", "request.body.write"], - mutation_fields: &["headers", "requestBody"], - timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, - default_failure_policy: DEFAULT_FAILURE_POLICY, - }, - HookContract { - id: "gateway.response.chunk", - kind: HookKind::Stream, - status: HookStatus::Active, - read_permissions: &["stream.inspect"], - write_permissions: &["stream.modify"], - mutation_fields: &["streamChunk"], - timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, - default_failure_policy: DEFAULT_FAILURE_POLICY, - }, - HookContract { - id: "gateway.response.after", - kind: HookKind::Response, - status: HookStatus::Active, - read_permissions: &["response.header.read", "response.body.read"], - write_permissions: &["response.header.write", "response.body.write"], - mutation_fields: &["headers", "responseBody"], - timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, - default_failure_policy: DEFAULT_FAILURE_POLICY, - }, - HookContract { - id: "gateway.error", - kind: HookKind::Response, - status: HookStatus::Active, - read_permissions: &["response.header.read", "response.body.read"], - write_permissions: &["response.header.write", "response.body.write"], - mutation_fields: &["headers", "responseBody"], - timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, - default_failure_policy: DEFAULT_FAILURE_POLICY, - }, - HookContract { - id: "log.beforePersist", - kind: HookKind::Log, - status: HookStatus::Active, - read_permissions: &["log.redact"], - write_permissions: &["log.redact"], - mutation_fields: &["logMessage"], - timeout_ms: DEFAULT_HOOK_TIMEOUT_MS, - default_failure_policy: DEFAULT_FAILURE_POLICY, - }, -]; - -pub(crate) const RESERVED_HOOKS: &[&str] = &[ - "gateway.request.received", - "gateway.request.beforeProviderResolution", - "gateway.response.headers", -]; - -pub(crate) const RESERVED_PERMISSIONS: &[&str] = &[ - "plugin.storage", - "network.fetch", - "file.read", - "file.write", - "secret.read", -]; - -pub(crate) fn hook_contract(id: &str) -> Option<&'static HookContract> { - ACTIVE_HOOKS.iter().find(|hook| hook.id == id) -} - -pub(crate) fn is_active_hook(id: &str) -> bool { - hook_contract(id).is_some() -} - -pub(crate) fn is_reserved_hook(id: &str) -> bool { - RESERVED_HOOKS.iter().any(|hook| *hook == id) -} - -pub(crate) fn is_known_hook(id: &str) -> bool { - is_active_hook(id) || is_reserved_hook(id) -} - -pub(crate) fn is_reserved_permission(permission: &str) -> bool { - RESERVED_PERMISSIONS.iter().any(|item| *item == permission) -} -``` - -- [ ] **Step 4: Export the contract module** - -Modify `src-tauri/src/gateway/plugins/mod.rs`: - -```rust -pub(crate) mod audit; -pub(crate) mod context; -pub(crate) mod contract; -pub(crate) mod permissions; -pub(crate) mod pipeline; -``` - -- [ ] **Step 5: Use contract metadata in domain validation** - -In `src-tauri/src/domain/plugins.rs`, replace the bodies of `is_known_hook`, `is_active_gateway_hook`, `is_reserved_gateway_hook`, and `is_reserved_permission`: - -```rust -pub fn is_known_hook(hook: &str) -> bool { - crate::gateway::plugins::contract::is_known_hook(hook) -} - -pub fn is_active_gateway_hook(hook: &str) -> bool { - crate::gateway::plugins::contract::is_active_hook(hook) -} - -pub fn is_reserved_gateway_hook(hook: &str) -> bool { - crate::gateway::plugins::contract::is_reserved_hook(hook) -} - -pub fn is_reserved_permission(permission: &str) -> bool { - crate::gateway::plugins::contract::is_reserved_permission(permission) -} -``` - -- [ ] **Step 6: Run focused Rust tests** - -Run: - -```bash -cd src-tauri && cargo test active_hook_metadata_matches_plugin_api_v1 --lib -cd src-tauri && cargo test reserved_hook_metadata_matches_plugin_api_v1 --lib -cd src-tauri && cargo test plugin_manifest --lib -``` - -Expected: all pass. - -- [ ] **Step 7: Run contract drift check** - -Run: - -```bash -pnpm check:plugin-api-contract -``` - -Expected: PASS. - -- [ ] **Step 8: Commit Rust contract metadata** - -Run: - -```bash -git add src-tauri/src/gateway/plugins/contract.rs src-tauri/src/gateway/plugins/mod.rs src-tauri/src/domain/plugins.rs -git commit -m "refactor(plugins): centralize rust plugin contract metadata" -``` - -Expected: commit contains only Rust contract metadata and validation call-through changes. - -## Hook Registry Layer - -### Task 3: Introduce Hook Registry Descriptors - -**Files:** -- Create: `src-tauri/src/gateway/plugins/registry.rs` -- Modify: `src-tauri/src/gateway/plugins/mod.rs` -- Modify: `src-tauri/src/gateway/plugins/context.rs` -- Test: Rust unit tests in `src-tauri/src/gateway/plugins/registry.rs` - -- [ ] **Step 1: Write registry tests** - -Create `registry.rs` with tests: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::gateway::plugins::context::GatewayPluginHookName; - - #[test] - fn registry_resolves_active_request_hook() { - let registry = HookRegistry::new(); - let descriptor = registry - .descriptor(GatewayPluginHookName::RequestAfterBodyRead) - .expect("descriptor"); - assert_eq!(descriptor.id, "gateway.request.afterBodyRead"); - assert_eq!(descriptor.kind, HookKind::Request); - assert!(descriptor.allows_read_permission("request.body.read")); - assert!(descriptor.allows_mutation_field("requestBody")); - } - - #[test] - fn registry_marks_stream_chunk_as_stream_kind() { - let registry = HookRegistry::new(); - let descriptor = registry - .descriptor(GatewayPluginHookName::ResponseChunk) - .expect("descriptor"); - assert_eq!(descriptor.kind, HookKind::Stream); - assert!(descriptor.allows_read_permission("stream.inspect")); - assert!(descriptor.allows_write_permission("stream.modify")); - } -} -``` - -- [ ] **Step 2: Run registry tests and verify compile failure** - -Run: - -```bash -cd src-tauri && cargo test registry_resolves_active_request_hook --lib -``` - -Expected: FAIL because `HookRegistry` is not implemented. - -- [ ] **Step 3: Implement HookRegistry** - -Add implementation above the tests: - -```rust -use super::contract::{self, HookKind}; -use super::context::GatewayPluginHookName; - -#[derive(Debug, Clone, Copy)] -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.iter().any(|item| *item == permission) - } - - pub(crate) fn allows_write_permission(self, permission: &str) -> bool { - self.write_permissions.iter().any(|item| *item == permission) - } - - pub(crate) fn allows_mutation_field(self, field: &str) -> bool { - self.mutation_fields.iter().any(|item| *item == field) - } -} - -#[derive(Debug, Default, Clone, Copy)] -pub(crate) struct HookRegistry; - -impl HookRegistry { - pub(crate) fn new() -> Self { - Self - } - - pub(crate) fn descriptor(self, hook_name: GatewayPluginHookName) -> Option { - let contract = 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, - }) - } -} -``` - -- [ ] **Step 4: Export registry module** - -Modify `src-tauri/src/gateway/plugins/mod.rs`: - -```rust -pub(crate) mod registry; -``` - -- [ ] **Step 5: Add string conversion helper on hook names** - -In `src-tauri/src/gateway/plugins/context.rs`, add: - -```rust -impl GatewayPluginHookName { - 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, - } - } -} -``` - -- [ ] **Step 6: Run registry tests** - -Run: - -```bash -cd src-tauri && cargo test registry_resolves_active_request_hook --lib -cd src-tauri && cargo test registry_marks_stream_chunk_as_stream_kind --lib -``` - -Expected: PASS. - -- [ ] **Step 7: Commit hook registry skeleton** - -Run: - -```bash -git add src-tauri/src/gateway/plugins/registry.rs src-tauri/src/gateway/plugins/mod.rs src-tauri/src/gateway/plugins/context.rs -git commit -m "refactor(plugins): add internal hook registry" -``` - -Expected: commit contains registry skeleton and hook string conversion. - -### Task 4: Move Mutation Enforcement Behind Descriptors - -**Files:** -- Create: `src-tauri/src/gateway/plugins/mutation.rs` -- Modify: `src-tauri/src/gateway/plugins/mod.rs` -- Modify: `src-tauri/src/gateway/plugins/permissions.rs` -- Modify: `src-tauri/src/gateway/plugins/pipeline.rs` -- Test: Rust unit tests in `mutation.rs` - -- [ ] **Step 1: Write descriptor-driven mutation tests** - -Create `mutation.rs` with tests: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::gateway::plugins::context::{ - GatewayHookAction, GatewayHookResult, GatewayPluginHookName, - }; - use crate::gateway::plugins::registry::HookRegistry; - - #[test] - fn request_body_mutation_requires_descriptor_permission() { - let descriptor = HookRegistry::new() - .descriptor(GatewayPluginHookName::RequestBeforeSend) - .expect("descriptor"); - let result = GatewayHookResult { - action: GatewayHookAction::Continue, - request_body: Some("changed".to_string()), - response_body: None, - stream_chunk: None, - headers: Default::default(), - log_message: None, - reason: None, - }; - - let err = enforce_descriptor_permissions(descriptor, &[], &result) - .expect_err("missing write permission"); - assert_eq!(err.code_for_logging(), "PLUGIN_PERMISSION_DENIED"); - - enforce_descriptor_permissions( - descriptor, - &["request.body.write".to_string()], - &result, - ) - .expect("permission granted"); - } -} -``` - -- [ ] **Step 2: Add `code_for_logging` accessor** - -In `src-tauri/src/gateway/plugins/permissions.rs`, add a non-test accessor: - -```rust -impl GatewayPluginError { - pub(crate) fn code_for_logging(&self) -> &'static str { - self.code - } -} -``` - -Keep the existing `#[cfg(test)] fn code(&self)` until all tests are migrated. - -- [ ] **Step 3: Run mutation test and verify compile failure** - -Run: - -```bash -cd src-tauri && cargo test request_body_mutation_requires_descriptor_permission --lib -``` - -Expected: FAIL because `enforce_descriptor_permissions` does not exist. - -- [ ] **Step 4: Implement descriptor-driven enforcement** - -Add implementation above tests in `mutation.rs`: - -```rust -use super::context::GatewayHookResult; -use super::permissions::GatewayPluginError; -use super::registry::HookDescriptor; - -pub(crate) fn enforce_descriptor_permissions( - descriptor: HookDescriptor, - permissions: &[String], - result: &GatewayHookResult, -) -> Result<(), GatewayPluginError> { - if result.request_body.is_some() { - require_mutation(descriptor, "requestBody", "request.body.write")?; - require_permission(permissions, "request.body.write")?; - } - if result.response_body.is_some() { - require_mutation(descriptor, "responseBody", "response.body.write")?; - require_permission(permissions, "response.body.write")?; - } - if result.stream_chunk.is_some() { - require_mutation(descriptor, "streamChunk", "stream.modify")?; - require_permission(permissions, "stream.modify")?; - } - if result.log_message.is_some() { - require_mutation(descriptor, "logMessage", "log.redact")?; - require_permission(permissions, "log.redact")?; - } - if !result.headers.is_empty() { - require_mutation(descriptor, "headers", header_write_permission(descriptor)?)?; - require_permission(permissions, header_write_permission(descriptor)?)?; - } - Ok(()) -} - -fn header_write_permission(descriptor: HookDescriptor) -> Result<&'static str, GatewayPluginError> { - if descriptor.allows_write_permission("request.header.write") { - Ok("request.header.write") - } else if descriptor.allows_write_permission("response.header.write") { - Ok("response.header.write") - } else { - Err(GatewayPluginError::new( - "PLUGIN_PERMISSION_DENIED", - format!("headers cannot be mutated in {}", descriptor.id), - )) - } -} - -fn require_mutation( - descriptor: HookDescriptor, - field: &'static str, - permission: &'static str, -) -> Result<(), GatewayPluginError> { - if descriptor.allows_mutation_field(field) && descriptor.allows_write_permission(permission) { - Ok(()) - } else { - Err(GatewayPluginError::new( - "PLUGIN_PERMISSION_DENIED", - format!("{field} mutation is not allowed in {}", descriptor.id), - )) - } -} - -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}"), - )) - } -} -``` - -- [ ] **Step 5: Export mutation module** - -Modify `src-tauri/src/gateway/plugins/mod.rs`: - -```rust -pub(crate) mod mutation; -``` - -- [ ] **Step 6: Use descriptor enforcement in pipeline** - -In `src-tauri/src/gateway/plugins/pipeline.rs`, replace calls to `enforce_hook_result_permissions` with: - -```rust -let descriptor = crate::gateway::plugins::registry::HookRegistry::new() - .descriptor(input.hook_name) - .ok_or_else(|| GatewayPluginError::new( - "PLUGIN_UNKNOWN_HOOK", - format!("unknown plugin hook: {}", input.hook_name.as_str()), - ))?; -if let Err(err) = crate::gateway::plugins::mutation::enforce_descriptor_permissions( - descriptor, - &plugin.granted_permissions, - &result, -) { - // keep the existing failure/audit/fail-open or fail-closed branch unchanged -} -``` - -Apply the same pattern to request, response, stream, and log hook execution paths. - -- [ ] **Step 7: Keep compatibility wrapper** - -Leave `enforce_hook_result_permissions` in `permissions.rs` as a wrapper: - -```rust -pub(crate) fn enforce_hook_result_permissions( - hook_name: GatewayPluginHookName, - permissions: &[String], - result: &GatewayHookResult, -) -> Result<(), GatewayPluginError> { - let descriptor = crate::gateway::plugins::registry::HookRegistry::new() - .descriptor(hook_name) - .ok_or_else(|| { - GatewayPluginError::new( - "PLUGIN_UNKNOWN_HOOK", - format!("unknown plugin hook: {}", hook_name.as_str()), - ) - })?; - crate::gateway::plugins::mutation::enforce_descriptor_permissions( - descriptor, - permissions, - result, - ) -} -``` - -- [ ] **Step 8: Run mutation and pipeline tests** - -Run: - -```bash -cd src-tauri && cargo test request_body_mutation_requires_descriptor_permission --lib -cd src-tauri && cargo test gateway_plugin_context_permission_enforces_write_permissions --lib -cd src-tauri && cargo test gateway_plugin_response_pipeline_applies_body_and_header_changes --lib -``` - -Expected: all pass. - -- [ ] **Step 9: Commit mutation descriptor enforcement** - -Run: - -```bash -git add src-tauri/src/gateway/plugins/mutation.rs src-tauri/src/gateway/plugins/mod.rs src-tauri/src/gateway/plugins/permissions.rs src-tauri/src/gateway/plugins/pipeline.rs -git commit -m "refactor(plugins): enforce mutations through hook descriptors" -``` - -Expected: commit contains descriptor enforcement only. - -## Runtime Layer - -### Task 5: Introduce Runtime Policy and Runtime Manager Facade - -**Files:** -- Create: `src-tauri/src/app/plugins/runtime_policy.rs` -- Create: `src-tauri/src/app/plugins/runtime_manager.rs` -- Modify: `src-tauri/src/app/plugins/mod.rs` -- Modify: `src-tauri/src/app/plugins/runtime_executor.rs` -- Test: Rust unit tests in `runtime_manager.rs` - -- [ ] **Step 1: Write runtime manager tests** - -Create `runtime_manager.rs` with tests: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::app::plugins::runtime_policy::RuntimePolicy; - use crate::domain::plugins::PluginRuntime; - - #[test] - fn runtime_manager_rejects_wasm_when_policy_disabled() { - let manager = PluginRuntimeManager::for_tests(RuntimePolicy { - wasm_enabled: false, - process_enabled: false, - }); - let err = manager - .validate_runtime_policy(&PluginRuntime::Wasm { - abi_version: "1.0.0".to_string(), - memory_limit_bytes: Some(16 * 1024 * 1024), - }) - .expect_err("wasm disabled"); - assert_eq!(err.code_for_logging(), "PLUGIN_RUNTIME_DISABLED"); - } - - #[test] - fn runtime_manager_allows_declarative_rules_policy() { - let manager = PluginRuntimeManager::for_tests(RuntimePolicy::default()); - manager - .validate_runtime_policy(&PluginRuntime::DeclarativeRules { - rules: vec!["rules/main.json".to_string()], - }) - .expect("declarative rules allowed"); - } -} -``` - -- [ ] **Step 2: Add runtime policy module** - -Create `runtime_policy.rs`: - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct RuntimePolicy { - pub(crate) wasm_enabled: bool, - pub(crate) process_enabled: bool, -} - -impl Default for RuntimePolicy { - fn default() -> Self { - Self { - wasm_enabled: false, - process_enabled: false, - } - } -} -``` - -- [ ] **Step 3: Run tests and verify compile failure** - -Run: - -```bash -cd src-tauri && cargo test runtime_manager_rejects_wasm_when_policy_disabled --lib -``` - -Expected: FAIL because `PluginRuntimeManager` is not implemented. - -- [ ] **Step 4: Implement runtime manager policy facade** - -Add implementation above tests in `runtime_manager.rs`: - -```rust -use crate::app::plugins::runtime_policy::RuntimePolicy; -use crate::domain::plugins::PluginRuntime; -use crate::gateway::plugins::permissions::GatewayPluginError; - -#[derive(Default)] -pub(crate) struct PluginRuntimeManager { - policy: RuntimePolicy, -} - -impl PluginRuntimeManager { - #[cfg(test)] - pub(crate) fn for_tests(policy: RuntimePolicy) -> Self { - Self { policy } - } - - pub(crate) fn new(policy: RuntimePolicy) -> Self { - Self { policy } - } - - pub(crate) fn validate_runtime_policy( - &self, - runtime: &PluginRuntime, - ) -> Result<(), GatewayPluginError> { - match runtime { - PluginRuntime::DeclarativeRules { .. } => Ok(()), - PluginRuntime::Native { engine } if engine == "privacyFilter" => Ok(()), - PluginRuntime::Native { engine } => Err(GatewayPluginError::new( - "PLUGIN_UNSUPPORTED_RUNTIME", - format!("unsupported native plugin runtime engine: {engine}"), - )), - PluginRuntime::Wasm { .. } if !self.policy.wasm_enabled => { - Err(GatewayPluginError::new( - "PLUGIN_RUNTIME_DISABLED", - "wasm runtime execution is disabled by host policy", - )) - } - PluginRuntime::Wasm { .. } => Err(GatewayPluginError::new( - "PLUGIN_WASM_NOT_WIRED", - "wasm runtime policy is enabled but gateway execution is not wired in this release", - )), - } - } -} -``` - -- [ ] **Step 5: Export runtime modules** - -Modify `src-tauri/src/app/plugins/mod.rs`: - -```rust -pub(crate) mod runtime_manager; -pub(crate) mod runtime_policy; -``` - -- [ ] **Step 6: Delegate old executor policy checks** - -In `runtime_executor.rs`, keep the public struct but call the manager: - -```rust -let manager = crate::app::plugins::runtime_manager::PluginRuntimeManager::new( - crate::app::plugins::runtime_policy::RuntimePolicy { - wasm_enabled: self.policy.wasm_enabled, - process_enabled: false, - }, -); -manager.validate_runtime_policy(&plugin.manifest.runtime)?; -``` - -Place this at the start of `execute_plugin_sync`, then keep the existing declarative/native dispatch branches. The WASM branches become unreachable after policy validation, so return the same existing errors if they are still matched. - -- [ ] **Step 7: Run focused runtime tests** - -Run: - -```bash -cd src-tauri && cargo test runtime_manager_rejects_wasm_when_policy_disabled --lib -cd src-tauri && cargo test runtime_executor_returns_clear_error_for_policy_disabled_wasm --lib -``` - -Expected: all pass and error code remains `PLUGIN_RUNTIME_DISABLED`. - -- [ ] **Step 8: Commit runtime manager facade** - -Run: - -```bash -git add src-tauri/src/app/plugins/runtime_manager.rs src-tauri/src/app/plugins/runtime_policy.rs src-tauri/src/app/plugins/mod.rs src-tauri/src/app/plugins/runtime_executor.rs -git commit -m "refactor(plugins): introduce runtime manager policy facade" -``` - -Expected: commit keeps external runtime behavior unchanged. - -### Task 6: Extract Runtime Cache Helpers - -**Files:** -- Create: `src-tauri/src/app/plugins/runtime_cache.rs` -- Modify: `src-tauri/src/app/plugins/mod.rs` -- Modify: `src-tauri/src/app/plugins/rule_runtime.rs` -- Test: Rust unit tests in `runtime_cache.rs` - -- [ ] **Step 1: Write cache key tests** - -Create `runtime_cache.rs` with tests: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cache_key_changes_with_updated_at() { - let a = RuntimeCacheKeyInput { - plugin_id: "acme.redactor", - version: "1.0.0", - installed_dir: "/tmp/acme", - updated_at: 1, - runtime_key: "rules/main.json", - }; - let b = RuntimeCacheKeyInput { updated_at: 2, ..a }; - assert_ne!(runtime_cache_key(a), runtime_cache_key(b)); - } - - #[test] - fn cache_key_includes_runtime_key() { - let a = RuntimeCacheKeyInput { - plugin_id: "acme.redactor", - version: "1.0.0", - installed_dir: "/tmp/acme", - updated_at: 1, - runtime_key: "rules/a.json", - }; - let b = RuntimeCacheKeyInput { - runtime_key: "rules/b.json", - ..a - }; - assert_ne!(runtime_cache_key(a), runtime_cache_key(b)); - } -} -``` - -- [ ] **Step 2: Implement runtime cache key helper** - -Add above tests: - -```rust -#[derive(Debug, Clone, Copy)] -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 - ) -} -``` - -- [ ] **Step 3: Export runtime cache module** - -Modify `src-tauri/src/app/plugins/mod.rs`: - -```rust -pub(crate) mod runtime_cache; -``` - -- [ ] **Step 4: Use helper in rule runtime cache keys** - -In `src-tauri/src/app/plugins/rule_runtime.rs`, replace the string formatting in `rule_runtime_cache_key` with: - -```rust -crate::app::plugins::runtime_cache::runtime_cache_key( - crate::app::plugins::runtime_cache::RuntimeCacheKeyInput { - plugin_id: &plugin.summary.plugin_id, - version, - installed_dir, - updated_at, - runtime_key: &rules, - }, -) -``` - -Replace `privacy_filter_cache_key` similarly, using runtime key `"native:privacyFilter"`. - -- [ ] **Step 5: Run cache and rule runtime tests** - -Run: - -```bash -cd src-tauri && cargo test cache_key_changes_with_updated_at --lib -cd src-tauri && cargo test cache_key_includes_runtime_key --lib -cd src-tauri && cargo test rule_runtime --lib -``` - -Expected: all pass. - -- [ ] **Step 6: Commit runtime cache extraction** - -Run: - -```bash -git add src-tauri/src/app/plugins/runtime_cache.rs src-tauri/src/app/plugins/mod.rs src-tauri/src/app/plugins/rule_runtime.rs -git commit -m "refactor(plugins): share runtime cache key helpers" -``` - -Expected: commit contains cache helper extraction only. - -## Provider Adapter Layer - -### Task 7: Add Provider Adapter Facade Without Moving Behavior - -**Files:** -- Create: `src-tauri/src/gateway/proxy/provider_adapters/mod.rs` -- Create: `src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs` -- Create: `src-tauri/src/gateway/proxy/provider_adapters/gemini_oauth.rs` -- Create: `src-tauri/src/gateway/proxy/provider_adapters/codex_chatgpt.rs` -- Create: `src-tauri/src/gateway/proxy/provider_adapters/claude.rs` -- Modify: `src-tauri/src/gateway/proxy/mod.rs` -- Test: Rust unit tests in `provider_adapters/mod.rs` - -- [ ] **Step 1: Write provider adapter registry tests** - -Create `provider_adapters/mod.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn registry_identifies_cx2cc_bridge_capability() { - let caps = ProviderCapabilities { - cx2cc_bridge: true, - ..ProviderCapabilities::default() - }; - assert!(caps.cx2cc_bridge); - assert!(caps.supports_count_tokens_local_intercept()); - } - - #[test] - fn registry_default_capabilities_are_plain_provider() { - let caps = ProviderCapabilities::default(); - assert!(!caps.cx2cc_bridge); - assert!(!caps.gemini_oauth); - assert!(!caps.codex_chatgpt_backend); - } -} -``` - -- [ ] **Step 2: Implement provider capability facade** - -Add above tests: - -```rust -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 - } -} -``` - -- [ ] **Step 3: Add empty adapter modules with explicit purpose** - -Create each module with a narrow compatibility shim. - -`cx2cc.rs`: - -```rust -pub(crate) fn is_count_tokens_intercept_supported( - is_claude_count_tokens: bool, - capabilities: super::ProviderCapabilities, -) -> bool { - is_claude_count_tokens && capabilities.supports_count_tokens_local_intercept() -} -``` - -`gemini_oauth.rs`: - -```rust -pub(crate) fn is_gemini_oauth(capabilities: super::ProviderCapabilities) -> bool { - capabilities.gemini_oauth -} -``` - -`codex_chatgpt.rs`: - -```rust -pub(crate) fn is_codex_chatgpt_backend(capabilities: super::ProviderCapabilities) -> bool { - capabilities.codex_chatgpt_backend -} -``` - -`claude.rs`: - -```rust -pub(crate) fn is_anthropic_compatible(capabilities: super::ProviderCapabilities) -> bool { - capabilities.anthropic_compatible -} -``` - -- [ ] **Step 4: Export provider adapter module** - -Modify `src-tauri/src/gateway/proxy/mod.rs`: - -```rust -pub(crate) mod provider_adapters; -``` - -- [ ] **Step 5: Run provider adapter tests** - -Run: - -```bash -cd src-tauri && cargo test registry_identifies_cx2cc_bridge_capability --lib -cd src-tauri && cargo test registry_default_capabilities_are_plain_provider --lib -``` - -Expected: PASS. - -- [ ] **Step 6: Commit provider adapter facade** - -Run: - -```bash -git add src-tauri/src/gateway/proxy/provider_adapters src-tauri/src/gateway/proxy/mod.rs -git commit -m "refactor(gateway): add provider adapter capability facade" -``` - -Expected: commit adds facade only and does not change gateway behavior. - -### Task 8: Route CX2CC Count Tokens Through Provider Adapter Facade - -**Files:** -- Modify: `src-tauri/src/gateway/proxy/handler/middleware/cx2cc_count_tokens_interceptor.rs` -- Modify: `src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs` -- Test: existing tests in `cx2cc_count_tokens_interceptor.rs` - -- [ ] **Step 1: Write a facade-focused CX2CC test** - -In `cx2cc_count_tokens_interceptor.rs`, add: - -```rust -#[test] -fn cx2cc_count_tokens_uses_adapter_capability() { - let capabilities = crate::gateway::proxy::provider_adapters::ProviderCapabilities { - cx2cc_bridge: true, - ..Default::default() - }; - assert!( - crate::gateway::proxy::provider_adapters::cx2cc::is_count_tokens_intercept_supported( - true, - capabilities - ) - ); -} -``` - -- [ ] **Step 2: Run the test** - -Run: - -```bash -cd src-tauri && cargo test cx2cc_count_tokens_uses_adapter_capability --lib -``` - -Expected: PASS if Task 7 facade exists. If it fails, fix facade exports before changing behavior. - -- [ ] **Step 3: Add provider-to-capabilities conversion** - -In `provider_adapters/cx2cc.rs`, add: - -```rust -pub(crate) fn capabilities_for_provider( - provider: &crate::providers::ProviderForGateway, -) -> super::ProviderCapabilities { - super::ProviderCapabilities { - cx2cc_bridge: provider.is_cx2cc_bridge(), - ..Default::default() - } -} -``` - -- [ ] **Step 4: Use facade in interceptor** - -Replace `should_intercept_cx2cc_count_tokens` body with: - -```rust -pub(in crate::gateway::proxy::handler) fn should_intercept_cx2cc_count_tokens( - is_claude_count_tokens: bool, - providers: &[providers::ProviderForGateway], -) -> bool { - providers.first().is_some_and(|provider| { - let capabilities = - crate::gateway::proxy::provider_adapters::cx2cc::capabilities_for_provider(provider); - crate::gateway::proxy::provider_adapters::cx2cc::is_count_tokens_intercept_supported( - is_claude_count_tokens, - capabilities, - ) - }) -} -``` - -- [ ] **Step 5: Run existing CX2CC tests** - -Run: - -```bash -cd src-tauri && cargo test intercepts_count_tokens_only_when_first_provider_is_cx2cc --lib -cd src-tauri && cargo test cx2cc_count_tokens_response_body_is_positive --lib -cd src-tauri && cargo test cx2cc_count_tokens_response_sets_intercept_headers --lib -``` - -Expected: all pass with identical behavior. - -- [ ] **Step 6: Commit CX2CC facade routing** - -Run: - -```bash -git add src-tauri/src/gateway/proxy/handler/middleware/cx2cc_count_tokens_interceptor.rs src-tauri/src/gateway/proxy/provider_adapters/cx2cc.rs -git commit -m "refactor(gateway): route cx2cc count tokens through provider adapter" -``` - -Expected: commit routes one provider-specific behavior through the adapter facade. - -## Integration and Documentation - -### Task 9: Add Compatibility Documentation for 0.62 Internal Refactor - -**Files:** -- Modify: `docs/plugins/architecture/README.md` -- Modify: `docs/plugins/architecture/audit.md` -- Modify: `docs/plugins/reference/compatibility.md` -- Modify: `scripts/check-plugin-system-docs.mjs` - -- [ ] **Step 1: Write docs checker assertion** - -In `scripts/check-plugin-system-docs.mjs`, add required phrases for `docs/plugins/reference/compatibility.md`: - -```javascript -const compatibility = readText("docs/plugins/reference/compatibility.md"); -for (const phrase of [ - "Plugin API v1 remains externally compatible in 0.62", - "0.62 does not add public provider plugin APIs", - "0.62 keeps third-party JavaScript and WebView plugin execution unsupported", -]) { - if (!compatibility.includes(phrase)) { - failures.push(`docs/plugins/reference/compatibility.md: missing "${phrase}"`); - } -} -``` - -- [ ] **Step 2: Run docs checker and verify failure** - -Run: - -```bash -pnpm check:plugin-system-docs -``` - -Expected: FAIL because compatibility docs do not yet include the new 0.62 statements. - -- [ ] **Step 3: Update compatibility docs** - -Add this section to `docs/plugins/reference/compatibility.md`: - -```markdown -## 0.62 Internal Platform Kernel - -Plugin API v1 remains externally compatible in 0.62. The release reorganizes host internals around contract metadata, hook descriptors, runtime policy, runtime cache lifecycle, and provider adapter facades. - -0.62 does not add public provider plugin APIs. Provider adapter work is host-internal and exists to reduce gateway/provider maintenance cost before any future public API is considered. - -0.62 keeps third-party JavaScript and WebView plugin execution unsupported. Community plugins continue to use `declarativeRules`; WASM remains controlled by host policy. -``` - -- [ ] **Step 4: Update architecture audit** - -Add a short 0.62 decision note to `docs/plugins/architecture/audit.md`: - -```markdown -## 0.62 Platform Kernel Decision - -0.62 keeps Plugin API v1 externally compatible and focuses on internal platform boundaries. Contract metadata becomes the source for drift checks; hook behavior is routed through internal descriptors; runtime dispatch is separated from pipeline orchestration; provider-specific behavior starts moving behind provider adapter facades. -``` - -- [ ] **Step 5: Run docs checks** - -Run: - -```bash -pnpm check:plugin-system-docs -pnpm check:plugin-api-contract -``` - -Expected: both pass. - -- [ ] **Step 6: Commit docs compatibility update** - -Run: - -```bash -git add docs/plugins/architecture/README.md docs/plugins/architecture/audit.md docs/plugins/reference/compatibility.md scripts/check-plugin-system-docs.mjs -git commit -m "docs: document 0.62 plugin platform compatibility" -``` - -Expected: commit contains docs and checker updates only. - -### Task 10: Full Verification Gate - -**Files:** -- No source changes unless a verification failure requires a focused fix - -- [ ] **Step 1: Run frontend and plugin checks** - -Run: - -```bash -pnpm lint -pnpm typecheck -pnpm check:no-instant-now-sub -pnpm check:plugin-api-contract -pnpm check:plugin-system-docs -pnpm check:plugin-system-completion -pnpm plugin-sdk:typecheck -pnpm --filter @aio-coding-hub/plugin-sdk test -pnpm create-aio-plugin:test -pnpm plugin-wasm-sdk:test -``` - -Expected: all pass. - -- [ ] **Step 2: Run Rust checks** - -Run: - -```bash -pnpm tauri:fmt -pnpm tauri:check -cd src-tauri && cargo test plugin --lib -cd src-tauri && cargo test provider --lib -cd src-tauri && cargo test gateway --lib -``` - -Expected: all pass. - -- [ ] **Step 3: Run focused gateway behavior tests** - -Run: - -```bash -cd src-tauri && cargo test gateway_plugin_response_pipeline_applies_body_and_header_changes --lib -cd src-tauri && cargo test gateway_plugin_stream_pipeline_applies_chunk_changes --lib -cd src-tauri && cargo test intercepts_count_tokens_only_when_first_provider_is_cx2cc --lib -cd src-tauri && cargo test runtime_executor_returns_clear_error_for_policy_disabled_wasm --lib -``` - -Expected: all pass, proving Plugin API v1 and first provider adapter facade behavior remain compatible. - -- [ ] **Step 4: Inspect final diff** - -Run: - -```bash -git status --short -git log --oneline -10 -``` - -Expected: worktree clean after all task commits; recent commits correspond to this plan's task commits. - -- [ ] **Step 5: Prepare completion summary** - -Write a short summary for the user containing: - -```text -- Contract checker now validates structured Plugin API v1 metadata. -- Rust hook metadata and HookRegistry centralize hook descriptors. -- Mutation enforcement uses descriptors while keeping Plugin API v1 behavior. -- Runtime policy/cache boundaries are separated from gateway pipeline orchestration. -- CX2CC count_tokens is the first provider-specific behavior routed through provider adapter facade. -- Verification commands run and their pass/fail status. -``` - -Expected: user can see what changed, what was verified, and what remains internal-only. - -## Plan Self-Review - -- Spec coverage: Contract, Hook Registry, Runtime, Provider Adapter, frontend/backend boundary, non-goals, behavior compatibility, tests, and performance concerns are represented by Tasks 0-10. -- Placeholder scan: no task uses open-ended instructions; each implementation task has concrete files, snippets, commands, and expected outcomes. -- Type consistency: names introduced in earlier tasks are reused consistently: `HookContract`, `HookRegistry`, `HookDescriptor`, `RuntimePolicy`, `PluginRuntimeManager`, `ProviderCapabilities`. diff --git a/scripts/check-plugin-api-contract.mjs b/scripts/check-plugin-api-contract.mjs index cadea254..4d637f58 100644 --- a/scripts/check-plugin-api-contract.mjs +++ b/scripts/check-plugin-api-contract.mjs @@ -106,8 +106,26 @@ if (contract) { if (entry.status !== "active") { failures.push(`hookMatrix.${hook}.status must be active`); } - requireArray(`hookMatrix.${hook}.readPermissions`, entry.readPermissions); - requireArray(`hookMatrix.${hook}.writePermissions`, entry.writePermissions); + const readPermissions = requireArray(`hookMatrix.${hook}.readPermissions`, entry.readPermissions); + const writePermissions = requireArray(`hookMatrix.${hook}.writePermissions`, entry.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}` + ); + } + } + } requireArray(`hookMatrix.${hook}.mutationFields`, entry.mutationFields); requireArray(`hookMatrix.${hook}.contextFields`, entry.contextFields); if (entry.defaultFailurePolicy !== contract.defaultFailurePolicy) { diff --git a/scripts/check-plugin-api-contract.selftest.mjs b/scripts/check-plugin-api-contract.selftest.mjs index bc5f216d..247df78a 100644 --- a/scripts/check-plugin-api-contract.selftest.mjs +++ b/scripts/check-plugin-api-contract.selftest.mjs @@ -170,6 +170,9 @@ 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( diff --git a/src-tauri/src/app/plugins/runtime_executor.rs b/src-tauri/src/app/plugins/runtime_executor.rs index 9337a3eb..c607f46e 100644 --- a/src-tauri/src/app/plugins/runtime_executor.rs +++ b/src-tauri/src/app/plugins/runtime_executor.rs @@ -38,20 +38,13 @@ impl RuntimeGatewayPluginExecutor { process_enabled: false, }); - match manager.runtime_dispatch(&plugin.manifest.runtime)? { + match manager.runtime_dispatch(&plugin.summary.plugin_id, &plugin.manifest.runtime)? { RuntimeDispatch::DeclarativeRules => self .rule_runtime .execute_declarative_rules_plugin(plugin, context), - RuntimeDispatch::NativePrivacyFilter - if plugin.summary.plugin_id == "official.privacy-filter" => - { - self.rule_runtime - .execute_official_privacy_filter_plugin(plugin, context) - } - RuntimeDispatch::NativePrivacyFilter => Err(GatewayPluginError::new( - "PLUGIN_UNSUPPORTED_RUNTIME", - "unsupported native plugin runtime engine: privacyFilter", - )), + RuntimeDispatch::NativePrivacyFilter => self + .rule_runtime + .execute_official_privacy_filter_plugin(plugin, context), RuntimeDispatch::WasmNotWired => Err(GatewayPluginError::new( "PLUGIN_WASM_NOT_WIRED", "wasm runtime policy is enabled but gateway execution is not wired in this release", diff --git a/src-tauri/src/app/plugins/runtime_manager.rs b/src-tauri/src/app/plugins/runtime_manager.rs index f26ed86b..2a945e59 100644 --- a/src-tauri/src/app/plugins/runtime_manager.rs +++ b/src-tauri/src/app/plugins/runtime_manager.rs @@ -40,6 +40,7 @@ impl PluginRuntimeManager { pub(crate) fn runtime_dispatch( &self, + plugin_id: &str, runtime: &PluginRuntime, ) -> Result { self.validate_runtime_policy(runtime)?; @@ -49,7 +50,9 @@ impl PluginRuntimeManager { match runtime { PluginRuntime::DeclarativeRules { .. } => Ok(RuntimeDispatch::DeclarativeRules), - PluginRuntime::Native { engine } if engine == "privacyFilter" => { + PluginRuntime::Native { engine } + if plugin_id == "official.privacy-filter" && engine == "privacyFilter" => + { Ok(RuntimeDispatch::NativePrivacyFilter) } PluginRuntime::Native { engine } => Err(GatewayPluginError::new( @@ -98,7 +101,7 @@ mod tests { .expect("declarative rules should be allowed by host policy"); assert_eq!( manager - .runtime_dispatch(&runtime) + .runtime_dispatch("example.rules", &runtime) .expect("declarative rules should resolve"), RuntimeDispatch::DeclarativeRules ); @@ -117,9 +120,27 @@ mod tests { assert_eq!( manager - .runtime_dispatch(&runtime) + .runtime_dispatch("example.wasm", &runtime) .expect("enabled wasm policy should reach dispatch decision"), RuntimeDispatch::WasmNotWired ); } + + #[test] + fn runtime_manager_rejects_non_official_native_privacy_filter() { + let manager = PluginRuntimeManager::for_tests(RuntimePolicy::default()); + let runtime = PluginRuntime::Native { + engine: "privacyFilter".to_string(), + }; + + let err = manager + .runtime_dispatch("example.privacy-filter", &runtime) + .expect_err("non-official native privacyFilter should be rejected by the manager"); + + assert_eq!(err.code(), "PLUGIN_UNSUPPORTED_RUNTIME"); + assert_eq!( + err.to_string(), + "PLUGIN_UNSUPPORTED_RUNTIME: unsupported native plugin runtime engine: privacyFilter" + ); + } } diff --git a/src-tauri/src/domain/plugins.rs b/src-tauri/src/domain/plugins.rs index e9094a7b..5b2e35f4 100644 --- a/src-tauri/src/domain/plugins.rs +++ b/src-tauri/src/domain/plugins.rs @@ -397,30 +397,21 @@ fn validate_hook_permissions( ) -> 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") => - { - return Err(PluginValidationError::new( - "PLUGIN_PERMISSION_MISMATCH", - "request.body.write requires request.body.read", - )); - } - "gateway.response.chunk" if has("stream.modify") && !has("stream.inspect") => { - return Err(PluginValidationError::new( - "PLUGIN_PERMISSION_MISMATCH", - "stream.modify requires stream.inspect", - )); + let Some(contract) = crate::gateway::plugins::contract::hook_contract(&hook.name) else { + continue; + }; + for dependency in contract.permission_dependencies { + if !has(dependency.permission) { + continue; } - "gateway.response.after" - if has("response.body.write") && !has("response.body.read") => - { - return Err(PluginValidationError::new( - "PLUGIN_PERMISSION_MISMATCH", - "response.body.write requires response.body.read", - )); + for required in dependency.requires { + if !has(required) { + return Err(PluginValidationError::new( + "PLUGIN_PERMISSION_MISMATCH", + format!("{} requires {required}", dependency.permission), + )); + } } - _ => {} } } Ok(()) @@ -690,6 +681,17 @@ mod tests { } } + #[test] + fn validate_manifest_preserves_before_send_write_without_read_compatibility() { + let mut raw = valid_manifest(); + raw["hooks"][0]["name"] = serde_json::json!("gateway.request.beforeSend"); + raw["permissions"] = serde_json::json!(["request.body.write"]); + let manifest: PluginManifest = serde_json::from_value(raw).unwrap(); + + validate_manifest(&manifest, "0.56.0") + .expect("beforeSend write-only permission is part of Plugin API v1 compatibility"); + } + #[test] fn validate_manifest_rejects_reserved_permissions_until_host_apis_exist() { for permission in [ diff --git a/src-tauri/src/gateway/plugins/contract.rs b/src-tauri/src/gateway/plugins/contract.rs index b0ff0007..e5b6aca9 100644 --- a/src-tauri/src/gateway/plugins/contract.rs +++ b/src-tauri/src/gateway/plugins/contract.rs @@ -47,10 +47,17 @@ pub(crate) struct HookContract { 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 = 150; pub(crate) const DEFAULT_FAILURE_POLICY: &str = "fail-open"; const RESERVED_HEADER_POLICY: &str = "block-gateway-owned"; @@ -71,6 +78,10 @@ pub(crate) const ACTIVE_HOOKS: &[HookContract] = &[ "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", @@ -99,6 +110,7 @@ pub(crate) const ACTIVE_HOOKS: &[HookContract] = &[ "request.body.read", ], write_permissions: &["request.header.write", "request.body.write"], + permission_dependencies: &[], mutation_fields: &["headers", "requestBody"], context_fields: &[ "traceId", @@ -122,6 +134,10 @@ pub(crate) const ACTIVE_HOOKS: &[HookContract] = &[ 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"], }, @@ -135,6 +151,10 @@ pub(crate) const ACTIVE_HOOKS: &[HookContract] = &[ 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", @@ -153,6 +173,7 @@ pub(crate) const ACTIVE_HOOKS: &[HookContract] = &[ 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", @@ -171,6 +192,7 @@ pub(crate) const ACTIVE_HOOKS: &[HookContract] = &[ reserved_header_policy: RESERVED_HEADER_POLICY, read_permissions: &["log.redact"], write_permissions: &["log.redact"], + permission_dependencies: &[], mutation_fields: &["logMessage"], context_fields: &["traceId", "log.message"], }, @@ -187,6 +209,7 @@ pub(crate) const RESERVED_HOOKS: &[HookContract] = &[ reserved_header_policy: RESERVED_HEADER_POLICY, read_permissions: &[], write_permissions: &[], + permission_dependencies: &[], mutation_fields: &[], context_fields: &[], }, @@ -200,6 +223,7 @@ pub(crate) const RESERVED_HOOKS: &[HookContract] = &[ reserved_header_policy: RESERVED_HEADER_POLICY, read_permissions: &[], write_permissions: &[], + permission_dependencies: &[], mutation_fields: &[], context_fields: &[], }, @@ -213,6 +237,7 @@ pub(crate) const RESERVED_HOOKS: &[HookContract] = &[ reserved_header_policy: RESERVED_HEADER_POLICY, read_permissions: &[], write_permissions: &[], + permission_dependencies: &[], mutation_fields: &[], context_fields: &[], }, @@ -300,6 +325,43 @@ mod tests { 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 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 active_hook_metadata_matches_plugin_api_v1() { let contract = plugin_api_v1_contract(); @@ -420,4 +482,22 @@ mod tests { } 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/proxy/provider_adapters/claude.rs b/src-tauri/src/gateway/proxy/provider_adapters/claude.rs index 4e91896c..441b7984 100644 --- a/src-tauri/src/gateway/proxy/provider_adapters/claude.rs +++ b/src-tauri/src/gateway/proxy/provider_adapters/claude.rs @@ -1,5 +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 index a34c5430..7cf0fa03 100644 --- a/src-tauri/src/gateway/proxy/provider_adapters/codex_chatgpt.rs +++ b/src-tauri/src/gateway/proxy/provider_adapters/codex_chatgpt.rs @@ -1,5 +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/gemini_oauth.rs b/src-tauri/src/gateway/proxy/provider_adapters/gemini_oauth.rs index 9c32252f..cc176e16 100644 --- a/src-tauri/src/gateway/proxy/provider_adapters/gemini_oauth.rs +++ b/src-tauri/src/gateway/proxy/provider_adapters/gemini_oauth.rs @@ -1,5 +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 index 0a750c88..42aedce7 100644 --- a/src-tauri/src/gateway/proxy/provider_adapters/mod.rs +++ b/src-tauri/src/gateway/proxy/provider_adapters/mod.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - pub(crate) mod claude; pub(crate) mod codex_chatgpt; pub(crate) mod cx2cc; From b53075ba088b5f4a5e4645b755a4c7bf7d9f1859 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 15:11:38 +0800 Subject: [PATCH 013/244] docs: define 0.62 gateway-first plugin kernel plan --- ...0-62-gateway-first-plugin-kernel-design.md | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md diff --git a/docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md b/docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md new file mode 100644 index 00000000..62bd10c2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md @@ -0,0 +1,306 @@ +# aio-coding-hub 0.62 Gateway-first 插件内核迭代设计 + +日期:2026-06-22 + +## 决策 + +0.62 的插件迭代采用 Gateway-first 路线:先把 Gateway 插件平台做稳定,Provider 只做内部架构铺垫,不开放 Provider Plugin API。 + +本版本目标不是新增更多公开插件 API,而是让现有 Plugin API v1 成为可信、可维护、可扩展的平台内核。除非发现 v1 外部契约无法表达当前已支持的 Gateway 插件能力,否则不修改 `plugin.json`、SDK、hook 名称、permission 名称或现有运行语义。 + +## 当前现状 + +当前插件系统已经具备 v1 基础: + +- `plugin.json` v1、hooks、permissions、config schema、SDK、脚手架和 contract checker 已存在。 +- Active hooks 已覆盖请求、响应、流式输出、错误和日志:`gateway.request.afterBodyRead`、`gateway.request.beforeSend`、`gateway.response.chunk`、`gateway.response.after`、`gateway.error`、`log.beforePersist`。 +- Reserved hooks 仍不开放:`gateway.request.received`、`gateway.request.beforeProviderResolution`、`gateway.response.headers`。 +- `declarativeRules` 是稳定社区 runtime。 +- `official.privacy-filter` 是唯一官方 native runtime。 +- WASM 有 policy、SDK 和示例,但默认不作为 0.62 的公开稳定执行能力。 +- Process runtime 仅保留为 PoC 文档,不进入 0.62 发布目标。 + +0.62 分支已经开始内部平台化: + +- `docs/plugins/plugin-api-v1-contract.json` 成为 contract drift 检查来源。 +- Rust 侧已有 hook contract、hook registry、mutation descriptor、runtime manager、runtime policy、runtime cache。 +- Provider adapter/capability facade 已开始收敛 provider-specific 逻辑。 +- Official privacy filter 正在从 `rule_runtime` 迁出,形成独立 runtime ownership。 + +## 0.62 目标 + +### 1. 保持 Plugin API v1 外部兼容 + +0.62 不引入 Plugin API v2。现有插件作者面对的 `plugin.json`、SDK 类型、hooks、permissions、declarative rules、official privacy filter 配置语义保持兼容。 + +验收重点不是“新增 API 数量”,而是证明已有 API v1 不漂移、不破坏、能被测试和文档稳定描述。 + +### 2. 稳定 Gateway hook 平台 + +Gateway hook 是 0.62 的核心扩展点。每个 active hook 的阶段、读权限、写权限、context fields、mutation fields、timeout、failure policy、audit 行为都必须可从内部 descriptor/contract 中定位。 + +目标状态: + +- Manifest validation 与 hook registry 对 active/reserved hooks 认知一致。 +- Context trimming 与 permission enforcement 不再散落成多套语义。 +- Mutation enforcement 由 descriptor 驱动。 +- Reserved hooks 继续被拒绝,直到真实调用点和测试齐备。 + +### 3. Runtime ownership 清晰化 + +Pipeline 负责 hook orchestration;runtime 层负责加载、缓存、执行、policy 和错误归一。 + +0.62 需要明确三类 runtime: + +- `declarativeRules`:社区稳定 runtime。 +- `official.privacy-filter`:官方 host-owned native runtime。 +- WASM:policy-gated,0.62 不承诺完整 gateway 执行稳定性。 + +第三方 `native` runtime 继续拒绝。Process runtime 不进入公开能力。 + +### 4. Provider 只做内部铺垫 + +Provider 是未来插件系统的重要方向,但 0.62 不开放 Provider Plugin API。 + +本版本只做内部 adapter/capability facade: + +- 收敛 CX2CC、Gemini OAuth、Codex、Claude 等 provider-specific 行为。 +- 减少 gateway hot path 中分散的 provider 特例判断。 +- 保持 provider selection、failover、circuit、limits、session binding 归 gateway core 所有。 +- 不让插件影响 provider 路由决策。 + +### 5. 测试与验收成为 release gate + +0.62 的完成定义必须由测试证明,而不是靠人工判断架构“看起来更好”。 + +必要验证包括 contract checks、hook fixture tests、runtime tests、gateway integration tests、provider regression tests 和 performance smoke。 + +## 开发计划 + +### Phase 0:整理当前分支 + +目标:把正在进行的 privacy filter runtime 迁移变成独立、可审查的提交。 + +范围: + +- `RuleRuntimeGatewayPluginExecutor` 只负责 `declarativeRules`。 +- `OfficialPrivacyFilterRuntime` 负责 official privacy filter 的 load、execute、cache、prune。 +- `RuntimeGatewayPluginExecutor` 根据 runtime dispatch 分发到对应 runtime。 +- Official privacy filter 行为测试归属到 official runtime 模块。 + +验证: + +- `cargo test official_privacy_filter --lib` +- `cargo test rule_runtime_prunes_cache_entries_not_in_active_plugin_keys --lib` +- `cargo check --locked` +- `RUSTFLAGS=-Dwarnings cargo check --locked` + +### Phase 1:Contract Layer 加固 + +目标:让 `plugin-api-v1-contract.json` 成为 API v1 drift detection 的事实源。 + +范围: + +- Active/reserved hooks 一致性检查。 +- Active/reserved permissions 一致性检查。 +- Hook context fields、mutation fields、permission dependencies 一致性检查。 +- SDK、docs、scaffold、replay、Rust validation 的 drift 报错可定位。 + +不做: + +- 不全量生成 Rust/TS 代码。 +- 不改变公开 contract shape。 + +验证: + +- `pnpm check:plugin-api-contract` +- `node scripts/check-plugin-api-contract.selftest.mjs` +- 破坏性 fixture 能报告具体 drift 类型。 + +### Phase 2:Hook Registry 收口 + +目标:将 hook 语义集中到 `HookDescriptor` 和 contract metadata。 + +范围: + +- Pipeline 获取 hook timeout、failure policy、permission/mutation metadata 时优先通过 descriptor。 +- Context trimming 和 mutation enforcement 使用同一份 hook descriptor 语义。 +- Reserved hooks 继续被 manifest validation 拒绝。 + +验证: + +- 每个 active hook 覆盖 context trimming。 +- 每个 active hook 覆盖合法 mutation。 +- 每个 active hook 覆盖非法 mutation。 +- 每个 active hook 覆盖 failure policy 和 audit。 +- 无插件 fast path 不退化。 + +### Phase 3:Runtime Layer 收口 + +目标:保持 runtime 分发、policy、cache、error taxonomy 的边界清楚。 + +范围: + +- `RuntimeGatewayPluginExecutor` 作为 gateway plugin executor 入口。 +- `PluginRuntimeManager` 负责 dispatch/policy。 +- 各 runtime 自己拥有 cache。 +- cache key 继续包含 plugin id、version、installed dir、updated_at、runtime key。 +- Runtime errors 有稳定 code 和 audit/log 可诊断信息。 + +验证: + +- Declarative rules load/cache/reload/prune。 +- Official privacy filter load/cache/prune。 +- WASM disabled 返回稳定 `PLUGIN_RUNTIME_DISABLED`。 +- WASM enabled 但 gateway execution 未接入时返回稳定 `PLUGIN_WASM_NOT_WIRED`。 +- 非 official native privacy filter 被拒绝。 + +### Phase 4:Provider Adapter 内部铺垫 + +目标:只收敛已有 provider 特例,不开放 provider 插件 API。 + +范围: + +- Provider adapter/capability facade 继续承接 CX2CC、Gemini OAuth、Codex/Claude 兼容逻辑。 +- Gateway orchestration 通过 adapter/capability 查询 provider 特性。 +- 迁移时可让 adapter facade 委托 legacy helper,但新增 provider 特例不能继续散落到 hot path。 + +不做: + +- 不允许插件注册 provider adapter。 +- 不开放 provider route decision hook。 +- 不允许插件控制 failover、circuit、limits、session binding。 + +验证: + +- CX2CC count_tokens、bridge request/response、usage 相关测试保持通过。 +- Gemini OAuth body/response translation 保持通过。 +- Codex/Claude request/response/provider logging regression 保持通过。 +- `cargo test provider --lib` + +### Phase 5:文档与发布验收 + +目标:文档明确 0.62 是内部平台内核版本,不新增公开插件 API。 + +范围: + +- 更新插件架构审计。 +- 更新 runtime docs。 +- 更新 developer-facing compatibility note。 +- 列出 0.62 不包含 Provider Plugin API、JS/TS runtime、WebView plugin runtime。 + +验证: + +- `pnpm check:plugin-system-docs` +- 文档中没有暗示 0.62 已开放 provider 插件 API。 + +## 实现边界 + +### 包含 + +- Gateway Plugin API v1 contract 加固。 +- Descriptor-driven hook metadata。 +- Descriptor-driven permission/mutation enforcement。 +- Runtime dispatch、policy、cache lifecycle 收口。 +- Official privacy filter 独立 runtime。 +- Declarative rules 行为保持。 +- Provider adapter facade 内部化。 +- 文档、测试、验收矩阵补齐。 + +### 不包含 + +- 不新增 Plugin API v2。 +- 不开放 Provider Plugin API。 +- 不开放第三方 native runtime。 +- 不开放 JavaScript/TypeScript runtime。 +- 不让插件运行在 Tauri WebView。 +- 不开放任意桌面 host API。 +- 不把 Skill 市场合并进插件 runtime。 +- 不为了未来可能性修改 v1 外部 API。 + +## 测试用例矩阵 + +### Contract tests + +- Active hooks 与 Rust validation 一致。 +- Reserved hooks 与 Rust validation 一致。 +- Active permissions 与 docs/SDK 一致。 +- Reserved permissions 仍被 manifest validation 拒绝。 +- Permission dependencies 与 contract 一致。 +- Mutation fields 与 hook descriptor 一致。 +- Legacy `contextPatch` 不回到 active contract。 + +### Hook fixture tests + +每个 active hook 至少覆盖: + +- granted permissions 下能看到对应 context fields。 +- 未授权字段被裁剪。 +- 合法 mutation 被应用。 +- 未授权 mutation 被拒绝。 +- 不属于该 hook 的 mutation field 被拒绝。 +- hook error/timeout 进入对应 failure policy。 +- audit event 记录 plugin id、hook、outcome、failure policy。 + +### Runtime tests + +- Declarative rules 首次执行 load,后续命中 cache。 +- Declarative rules plugin version/updated_at/path 变化后 reload。 +- Declarative rules cache prune 只保留 active plugin keys。 +- Official privacy filter 首次执行 load,后续命中 cache。 +- Official privacy filter cache prune 只保留 active official key。 +- Non-official native `privacyFilter` 被拒绝。 +- WASM disabled 被拒绝。 +- WASM enabled but not wired 返回稳定错误。 + +### Gateway integration tests + +- `gateway.request.afterBodyRead` 能改写 request body。 +- `gateway.request.beforeSend` 能改写最终 upstream request body/header。 +- `gateway.response.after` 能改写 non-stream response body/header。 +- `gateway.response.chunk` 能改写或阻断 stream chunk。 +- `gateway.error` 能改写 gateway-generated error response,但失败不能隐藏 host error。 +- `log.beforePersist` 能在日志入库前脱敏。 + +### Provider regression tests + +- Provider selection、forced provider、session binding 不变。 +- Circuit、cooldown、limits 不变。 +- CX2CC bridge 行为不变。 +- Gemini OAuth 行为不变。 +- Codex/Claude compatibility 行为不变。 +- Request log、usage、provider chain JSON 形状不变。 + +### Performance smoke + +- Empty plugin pipeline request hook 低于既有预算。 +- One noop declarative plugin request hook 低于既有预算。 +- 无 `gateway.response.chunk` 插件时保持 stream direct path。 +- Declarative rules 首次解析后缓存生效。 +- Official privacy filter compiled detector 缓存生效。 + +## 验收标准 + +0.62 插件迭代完成必须满足: + +- 外部 Plugin API v1 兼容。 +- `pnpm check:plugin-api-contract` 通过。 +- `pnpm check:plugin-system-docs` 通过。 +- Rust plugin/gateway/provider 相关测试通过。 +- `cargo check --locked` 通过。 +- `RUSTFLAGS=-Dwarnings cargo check --locked` 通过。 +- Hook 语义能从 contract/descriptor 定位。 +- Runtime ownership 清楚:declarative rules、official privacy filter、WASM policy 不互相混杂。 +- Provider adapter 仍是内部 facade,不暴露给插件作者。 +- 文档明确 0.62 不新增公开插件 API。 +- Performance smoke 未暴露 gateway hot path 明显退化。 + +## 版本表述 + +推荐对外表述: + +> aio-coding-hub 0.62 focuses on the Gateway-first plugin platform kernel. It keeps Plugin API v1 compatible while hardening hook contracts, runtime ownership, permission enforcement, and internal provider adapter boundaries. + +推荐中文表述: + +> aio-coding-hub 0.62 聚焦 Gateway-first 插件平台内核。它保持 Plugin API v1 兼容,重点加固 hook 契约、runtime ownership、权限与 mutation enforcement,以及内部 provider adapter 边界。 From 34d25a81db3d10db4a1e1a54d31e10c40f569e17 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 15:21:43 +0800 Subject: [PATCH 014/244] docs: plan 0.62 gateway-first plugin kernel --- ...ng-hub-0-62-gateway-first-plugin-kernel.md | 649 ++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md diff --git a/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md b/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md new file mode 100644 index 00000000..c1587dbd --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md @@ -0,0 +1,649 @@ +# aio-coding-hub 0.62 Gateway-first Plugin Kernel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the 0.62 Gateway-first plugin platform kernel while keeping Plugin API v1 externally compatible and keeping Provider Plugin API private. + +**Architecture:** Keep the public plugin surface stable and harden the internal layers that serve it. Gateway hook semantics come from contract/descriptor metadata, runtimes own their own cache and execution concerns, and provider-specific behavior remains behind internal adapter/capability facades. + +**Tech Stack:** Rust/Tauri 2, Axum gateway plugin pipeline, serde/serde_json, Node.js contract scripts, pnpm, cargo. + +--- + +## File Structure + +- `docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md` + Source specification for this plan. +- `src-tauri/src/app/plugins/official_privacy_filter_runtime.rs` + New official native privacy filter runtime. Owns loading, execution, cache, prune, and behavior tests for `official.privacy-filter`. +- `src-tauri/src/app/plugins/rule_runtime.rs` + Declarative rules runtime only. Must not contain official privacy filter execution or cache ownership. +- `src-tauri/src/app/plugins/runtime_executor.rs` + Runtime gateway executor. Dispatches to declarative rules, official privacy filter, or stable WASM-not-wired error. +- `src-tauri/src/app/plugins/mod.rs` + Registers the new official privacy filter runtime module. +- `src-tauri/src/gateway/plugins/contract.rs` + Rust Plugin API v1 metadata. Add uniqueness tests and fix duplicate context field entries. +- `src-tauri/src/gateway/plugins/registry.rs` + Hook descriptor facade over contract metadata. Add all-contract descriptor coverage. +- `src-tauri/src/gateway/plugins/pipeline.rs` + Gateway hook orchestration. Add release-gate tests proving pipeline defaults remain aligned with contract defaults. +- `scripts/check-plugin-api-contract.mjs` + Node contract drift checker. Add duplicate-array checks for contract hook metadata. +- `scripts/check-plugin-api-contract.selftest.mjs` + Self-tests for the contract checker. Add failing fixture for duplicate hook metadata arrays. +- `docs/plugins/architecture/audit.md` + Maintainer architecture note. Clarify that Provider Plugin API remains private in 0.62. +- `docs/plugins/reference/compatibility.md` + Developer-facing compatibility note. Confirm 0.62 keeps Plugin API v1 compatible and does not add public provider plugin APIs. + +## Task 1: Commit Official Privacy Filter Runtime Split + +**Files:** +- Create: `src-tauri/src/app/plugins/official_privacy_filter_runtime.rs` +- Modify: `src-tauri/src/app/plugins/mod.rs` +- Modify: `src-tauri/src/app/plugins/rule_runtime.rs` +- Modify: `src-tauri/src/app/plugins/runtime_executor.rs` + +- [ ] **Step 1: Confirm the regression test exists** + +Open `src-tauri/src/app/plugins/runtime_executor.rs` and confirm the test module contains this test: + +```rust +#[test] +fn runtime_executor_retain_prunes_official_privacy_filter_runtime_cache() { + let executor = executor(); + let plugin = official_privacy_filter_plugin_detail(json!({ + "redactBeforeUpstream": true, + "redactLogs": true + })); + let context = hook_context("log.beforePersist", "trace-privacy"); + + executor + .execute_plugin_sync(&plugin, context) + .expect("official privacy filter runtime executes"); + assert_eq!(executor.privacy_filter_cache_size_for_tests(), 1); + + executor.retain_runtime_caches_for_plugins(&[]); + + assert_eq!(executor.privacy_filter_cache_size_for_tests(), 0); +} +``` + +- [ ] **Step 2: Verify the regression test passes against the current implementation** + +Run: + +```bash +cd src-tauri && cargo test runtime_executor_retain_prunes_official_privacy_filter_runtime_cache --lib +``` + +Expected: `1 passed; 0 failed`. + +- [ ] **Step 3: Confirm runtime ownership boundaries in code** + +Confirm `src-tauri/src/app/plugins/rule_runtime.rs` contains no privacy-filter ownership: + +```bash +rg "PrivacyFilter|privacy_filter|official_privacy_filter|execute_official_privacy_filter" src-tauri/src/app/plugins/rule_runtime.rs +``` + +Expected: no matches. + +Confirm `src-tauri/src/app/plugins/runtime_executor.rs` dispatches native privacy filter to the new runtime: + +```rust +RuntimeDispatch::NativePrivacyFilter => { + self.privacy_filter_runtime.execute_plugin(plugin, context) +} +``` + +- [ ] **Step 4: Run focused behavior checks** + +Run: + +```bash +cd src-tauri && cargo test official_privacy_filter --lib +cd src-tauri && cargo test rule_runtime_prunes_cache_entries_not_in_active_plugin_keys --lib +``` + +Expected: + +- `official_privacy_filter --lib`: `32 passed; 0 failed` +- `rule_runtime_prunes_cache_entries_not_in_active_plugin_keys --lib`: `1 passed; 0 failed` + +- [ ] **Step 5: Run compile and formatting checks** + +Run: + +```bash +cd src-tauri && cargo fmt -- --check +cd src-tauri && cargo check --locked +cd src-tauri && RUSTFLAGS=-Dwarnings cargo check --locked +``` + +Expected: all commands exit `0`. + +- [ ] **Step 6: Commit the runtime split** + +Run: + +```bash +git add src-tauri/src/app/plugins/mod.rs \ + src-tauri/src/app/plugins/rule_runtime.rs \ + src-tauri/src/app/plugins/runtime_executor.rs \ + src-tauri/src/app/plugins/official_privacy_filter_runtime.rs +git commit -m "refactor(plugins): split official privacy filter runtime" +``` + +Expected: commit succeeds and contains only the runtime split. + +## Task 2: Add Rust Contract Uniqueness Gate + +**Files:** +- Modify: `src-tauri/src/gateway/plugins/contract.rs` + +- [ ] **Step 1: Write the failing duplicate-field test** + +Append this helper and test inside the existing `#[cfg(test)] mod tests` in `src-tauri/src/gateway/plugins/contract.rs`: + +```rust +fn assert_unique_slice(label: &str, hook: &str, values: &[&str]) { + let mut seen = std::collections::BTreeSet::new(); + for value in values { + assert!( + seen.insert(*value), + "{hook} {label} contains duplicate value {value}" + ); + } +} + +#[test] +fn hook_contract_arrays_do_not_contain_duplicates() { + for contract in ACTIVE_HOOKS.iter().chain(RESERVED_HOOKS.iter()) { + assert_unique_slice("read_permissions", contract.id, contract.read_permissions); + assert_unique_slice("write_permissions", contract.id, contract.write_permissions); + assert_unique_slice("mutation_fields", contract.id, contract.mutation_fields); + assert_unique_slice("context_fields", contract.id, contract.context_fields); + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails for the current duplicate** + +Run: + +```bash +cd src-tauri && cargo test hook_contract_arrays_do_not_contain_duplicates --lib +``` + +Expected before the fix: failure containing: + +```text +gateway.request.beforeSend context_fields contains duplicate value request.query +``` + +- [ ] **Step 3: Fix the duplicate context field** + +In `src-tauri/src/gateway/plugins/contract.rs`, update the `gateway.request.beforeSend` `context_fields` array from: + +```rust +context_fields: &[ + "traceId", + "request.cliKey", + "request.method", + "request.path", + "request.query", + "request.query", + "request.headers", + "request.body", + "request.requestedModel", + "request.normalizedMessages", +], +``` + +to: + +```rust +context_fields: &[ + "traceId", + "request.cliKey", + "request.method", + "request.path", + "request.query", + "request.headers", + "request.body", + "request.requestedModel", + "request.normalizedMessages", +], +``` + +- [ ] **Step 4: Run the test and verify it passes** + +Run: + +```bash +cd src-tauri && cargo test hook_contract_arrays_do_not_contain_duplicates --lib +``` + +Expected: `1 passed; 0 failed`. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add src-tauri/src/gateway/plugins/contract.rs +git commit -m "test(plugins): guard hook contract metadata uniqueness" +``` + +Expected: commit succeeds. + +## Task 3: Add Hook Registry Full Contract Coverage + +**Files:** +- Modify: `src-tauri/src/gateway/plugins/registry.rs` + +- [ ] **Step 1: Add a descriptor mirror test** + +Inside `#[cfg(test)] mod tests` in `src-tauri/src/gateway/plugins/registry.rs`, add this test: + +```rust +#[test] +fn registry_descriptors_mirror_every_hook_contract() { + let registry = HookRegistry::new(); + for contract in crate::gateway::plugins::contract::ACTIVE_HOOKS + .iter() + .chain(crate::gateway::plugins::contract::RESERVED_HOOKS.iter()) + { + let hook_name = GatewayPluginHookName::from_str(contract.id) + .unwrap_or_else(|| panic!("missing GatewayPluginHookName for {}", contract.id)); + let descriptor = registry + .descriptor(hook_name) + .unwrap_or_else(|| panic!("missing descriptor for {}", 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 + ); + } +} +``` + +- [ ] **Step 2: Run the focused test** + +Run: + +```bash +cd src-tauri && cargo test registry_descriptors_mirror_every_hook_contract --lib +``` + +Expected: `1 passed; 0 failed`. + +- [ ] **Step 3: Run existing registry tests** + +Run: + +```bash +cd src-tauri && cargo test gateway::plugins::registry --lib +``` + +Expected: all registry tests pass. + +- [ ] **Step 4: Commit** + +Run: + +```bash +git add src-tauri/src/gateway/plugins/registry.rs +git commit -m "test(plugins): cover hook registry contract descriptors" +``` + +Expected: commit succeeds. + +## Task 4: Add Pipeline Contract Default Alignment Test + +**Files:** +- Modify: `src-tauri/src/gateway/plugins/pipeline.rs` + +- [ ] **Step 1: Add the default timeout alignment test** + +Inside the existing `#[cfg(test)] mod tests` in `src-tauri/src/gateway/plugins/pipeline.rs`, add: + +```rust +#[test] +fn default_pipeline_timeout_matches_plugin_contract() { + assert_eq!( + GatewayPluginPipelineConfig::default().hook_timeout, + std::time::Duration::from_millis( + crate::gateway::plugins::contract::DEFAULT_HOOK_TIMEOUT_MS + ) + ); +} +``` + +- [ ] **Step 2: Run the focused test** + +Run: + +```bash +cd src-tauri && cargo test default_pipeline_timeout_matches_plugin_contract --lib +``` + +Expected: `1 passed; 0 failed`. + +- [ ] **Step 3: Run pipeline plugin tests** + +Run: + +```bash +cd src-tauri && cargo test gateway_plugin_pipeline --lib +``` + +Expected: all matching pipeline tests pass, with existing performance smoke tests still ignored. + +- [ ] **Step 4: Commit** + +Run: + +```bash +git add src-tauri/src/gateway/plugins/pipeline.rs +git commit -m "test(plugins): align pipeline defaults with contract" +``` + +Expected: commit succeeds. + +## Task 5: Add Contract Checker Duplicate Metadata Gate + +**Files:** +- Modify: `scripts/check-plugin-api-contract.mjs` +- Modify: `scripts/check-plugin-api-contract.selftest.mjs` + +- [ ] **Step 1: Add the failing self-test fixture** + +In `scripts/check-plugin-api-contract.selftest.mjs`, append this fixture near the other hook metadata fixtures: + +```js +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: ["declarativeRules"], + policyGatedRuntimes: ["wasm"], + officialRuntimes: ["native:privacyFilter"], +}); +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 duplicate hookMatrix metadata failure, got status ${duplicateHookMetadataResult.status}\n${duplicateHookMetadataResult.stderr}` + ); +} +``` + +- [ ] **Step 2: Run the self-test and verify it fails** + +Run: + +```bash +node scripts/check-plugin-api-contract.selftest.mjs +``` + +Expected before implementation: failure because the checker does not yet report duplicate `readPermissions`. + +- [ ] **Step 3: Implement duplicate array detection** + +In `scripts/check-plugin-api-contract.mjs`, add this helper after `requireArray`: + +```js +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); + } +} +``` + +Then, in the active hook loop, update the array reads to call the helper: + +```js +const readPermissions = requireArray(`hookMatrix.${hook}.readPermissions`, entry.readPermissions); +requireUniqueArray(`hookMatrix.${hook}.readPermissions`, readPermissions); +const writePermissions = requireArray(`hookMatrix.${hook}.writePermissions`, entry.writePermissions); +requireUniqueArray(`hookMatrix.${hook}.writePermissions`, writePermissions); +const mutationFields = requireArray(`hookMatrix.${hook}.mutationFields`, entry.mutationFields); +requireUniqueArray(`hookMatrix.${hook}.mutationFields`, mutationFields); +const contextFields = requireArray(`hookMatrix.${hook}.contextFields`, entry.contextFields); +requireUniqueArray(`hookMatrix.${hook}.contextFields`, contextFields); +``` + +Keep the existing permission dependency checks using `readPermissions` and `writePermissions`. + +- [ ] **Step 4: Run contract checks** + +Run: + +```bash +pnpm check:plugin-api-contract +node scripts/check-plugin-api-contract.selftest.mjs +``` + +Expected: both commands exit `0`. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add scripts/check-plugin-api-contract.mjs scripts/check-plugin-api-contract.selftest.mjs +git commit -m "test(plugins): reject duplicate hook contract metadata" +``` + +Expected: commit succeeds. + +## Task 6: Strengthen Provider API Non-Exposure Docs Gate + +**Files:** +- Modify: `docs/plugins/architecture/audit.md` +- Modify: `docs/plugins/reference/compatibility.md` +- Modify: `scripts/check-plugin-system-docs.mjs` + +- [ ] **Step 1: Add required phrases to docs check** + +In `scripts/check-plugin-system-docs.mjs`, extend the `docs/plugins/architecture/audit.md` entry so its `phrases` array includes: + +```js +"0.62 does not add public provider plugin APIs", +"provider adapter facades remain internal", +``` + +The entry should still include the existing phrases for `official.privacy-filter`, `declarativeRules`, `WASM`, `native`, `信任边界`, and `性能与稳定性建议`. + +- [ ] **Step 2: Run docs check and verify it fails** + +Run: + +```bash +pnpm check:plugin-system-docs +``` + +Expected before doc updates: failure pointing at the missing provider API phrases in `docs/plugins/architecture/audit.md`. + +- [ ] **Step 3: Update architecture audit** + +In `docs/plugins/architecture/audit.md`, extend the `0.62 Platform Kernel Decision` section with this paragraph: + +```markdown +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. +``` + +- [ ] **Step 4: Confirm compatibility doc still has the public API boundary** + +Ensure `docs/plugins/reference/compatibility.md` contains: + +```markdown +Plugin API v1 remains externally compatible in 0.62 +0.62 does not add public provider plugin APIs +0.62 keeps third-party JavaScript and WebView plugin execution unsupported +``` + +If any line is missing, add it to the 0.62 compatibility section. + +- [ ] **Step 5: Run docs check** + +Run: + +```bash +pnpm check:plugin-system-docs +``` + +Expected: command exits `0`. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add docs/plugins/architecture/audit.md docs/plugins/reference/compatibility.md scripts/check-plugin-system-docs.mjs +git commit -m "docs(plugins): guard 0.62 provider api boundary" +``` + +Expected: commit succeeds. + +## Task 7: Run 0.62 Gateway-first Release Verification + +**Files:** +- Read: `docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md` +- Read: `docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md` + +- [ ] **Step 1: Verify no public plugin API files changed unexpectedly** + +Run: + +```bash +git diff --name-only b53075ba..HEAD +``` + +Inspect the output. Expected for this plan: + +- No changes under `packages/plugin-sdk/` unless caused by a deliberate contract check fix. +- No changes under `packages/create-aio-plugin/` unless caused by a deliberate contract check fix. +- No `plugin.json` schema shape changes. +- No public provider plugin API docs. + +- [ ] **Step 2: Run Rust formatting and compile gates** + +Run: + +```bash +cd src-tauri && cargo fmt -- --check +cd src-tauri && cargo check --locked +cd src-tauri && RUSTFLAGS=-Dwarnings cargo check --locked +``` + +Expected: all commands exit `0`. + +- [ ] **Step 3: Run plugin-focused Rust tests** + +Run: + +```bash +cd src-tauri && cargo test plugin --lib +``` + +Expected: all plugin tests pass, with existing performance smoke tests still ignored. + +- [ ] **Step 4: Run provider-focused Rust tests** + +Run: + +```bash +cd src-tauri && cargo test provider --lib +``` + +Expected: all provider tests pass. + +- [ ] **Step 5: Run targeted gateway hook tests** + +Run: + +```bash +cd src-tauri && cargo test gateway_plugin_pipeline --lib +cd src-tauri && cargo test gateway_plugin_request --lib +cd src-tauri && cargo test gateway_plugin_response --lib +cd src-tauri && cargo test plugin_log_redaction --lib +``` + +Expected: all matching tests pass. + +- [ ] **Step 6: Run contract and docs gates** + +Run: + +```bash +pnpm check:plugin-api-contract +pnpm check:plugin-system-docs +node scripts/check-plugin-api-contract.selftest.mjs +``` + +Expected: all commands exit `0`. + +- [ ] **Step 7: Commit final verification notes if docs changed** + +If verification requires a small documentation note, commit it: + +```bash +git add docs/plugins +git add -f docs/superpowers/specs docs/superpowers/plans +git commit -m "docs(plugins): record 0.62 gateway-first verification" +``` + +If no files changed, do not create an empty commit. + +- [ ] **Step 8: Report final status** + +Report: + +- final commit list, +- verification commands and pass/fail status, +- any ignored performance smoke tests, +- confirmation that Plugin API v1 remains externally compatible, +- confirmation that Provider Plugin API was not opened. From 3b8ed702a93d6bd7895dd60fa40a60fffeb33380 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 15:30:05 +0800 Subject: [PATCH 015/244] refactor(plugins): split official privacy filter runtime --- src-tauri/src/app/plugins/mod.rs | 1 + .../official_privacy_filter_runtime.rs | 1205 +++++++++++++++++ src-tauri/src/app/plugins/rule_runtime.rs | 1160 +--------------- src-tauri/src/app/plugins/runtime_executor.rs | 70 +- 4 files changed, 1274 insertions(+), 1162 deletions(-) create mode 100644 src-tauri/src/app/plugins/official_privacy_filter_runtime.rs diff --git a/src-tauri/src/app/plugins/mod.rs b/src-tauri/src/app/plugins/mod.rs index f2d12ced..45962332 100644 --- a/src-tauri/src/app/plugins/mod.rs +++ b/src-tauri/src/app/plugins/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod official; pub(crate) mod official_assets; +pub(crate) mod official_privacy_filter_runtime; pub(crate) mod privacy_filter; pub(crate) mod process_runtime; pub(crate) mod rule_runtime; diff --git a/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs b/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs new file mode 100644 index 00000000..34811891 --- /dev/null +++ b/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs @@ -0,0 +1,1205 @@ +//! Usage: Official native privacy filter plugin runtime. + +use super::privacy_filter::{PrivacyFilter, PrivacyFilterError, PrivacyFilterOptions}; +use super::runtime_cache::{runtime_cache_key, RuntimeCacheKeyInput}; +use crate::gateway::plugins::context::{GatewayHookResult, GatewayVisibleHookContext}; +use crate::gateway::plugins::permissions::GatewayPluginError; +use crate::plugins::PluginDetail; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::sync::{Arc, Mutex}; + +#[derive(Default)] +pub(crate) struct OfficialPrivacyFilterRuntime { + cache: Mutex>>, +} + +impl OfficialPrivacyFilterRuntime { + pub(crate) fn execute_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 privacy_keys: HashSet = plugins + .iter() + .filter(|plugin| plugin.summary.plugin_id == "official.privacy-filter") + .map(privacy_filter_cache_key) + .collect(); + self.cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .retain(|key, _| privacy_keys.contains(key)); + } + + fn get_or_load_privacy_filter( + &self, + plugin: &PluginDetail, + ) -> Result, GatewayPluginError> { + let cache_key = privacy_filter_cache_key(plugin); + { + let 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_official_privacy_filter(plugin).map_err(to_privacy_filter_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(&filter)), + )) + } + + #[cfg(test)] + pub(crate) fn cache_size_for_tests(&self) -> usize { + self.cache.lock().unwrap().len() + } +} + +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: "native:privacyFilter", + }) +} + +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()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gateway::plugins::context::{ + GatewayVisibleHookContext, GatewayVisibleLogContext, GatewayVisibleRequestContext, + GatewayVisibleResponseContext, GatewayVisibleStreamContext, + }; + use crate::plugins::{ + PluginDetail, PluginInstallSource, PluginPermissionRisk, PluginStatus, PluginSummary, + }; + use serde_json::json; + + fn context_for_request_body_text(body: impl Into) -> GatewayVisibleHookContext { + GatewayVisibleHookContext { + hook_name: "gateway.request.afterBodyRead".to_string(), + trace_id: "trace-privacy-filter-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-privacy-filter-test".to_string(), + request: GatewayVisibleRequestContext::default(), + response: GatewayVisibleResponseContext::default(), + stream: GatewayVisibleStreamContext::default(), + log: GatewayVisibleLogContext { + message: Some(message.to_string()), + }, + } + } + + 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 = OfficialPrivacyFilterRuntime::default(); + let plugin = official_privacy_filter_detail(config); + let result = executor + .execute_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 + }) + } + + #[test] + fn official_privacy_filter_redacts_phone_numbers_in_provider_request_shapes() { + let executor = OfficialPrivacyFilterRuntime::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_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 = OfficialPrivacyFilterRuntime::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_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 = OfficialPrivacyFilterRuntime::default(); + let plugin = official_privacy_filter_detail(json!({ + "redactBeforeUpstream": true, + "redactLogs": true, + "redactionScopes": ["system_instructions", "user_prompts", "tool_results"] + })); + + let result = executor + .execute_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 = OfficialPrivacyFilterRuntime::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_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 = OfficialPrivacyFilterRuntime::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_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 = OfficialPrivacyFilterRuntime::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_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 = OfficialPrivacyFilterRuntime::default(); + let plugin = official_privacy_filter_detail(json!({ + "redactBeforeUpstream": true, + "redactLogs": true, + "sensitiveTypes": ["email"] + })); + + let result = executor + .execute_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 = OfficialPrivacyFilterRuntime::default(); + let plugin = official_privacy_filter_detail(json!({ + "redactBeforeUpstream": true, + "redactLogs": true, + "sensitiveTypes": [] + })); + + let result = executor + .execute_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" + ); + } +} diff --git a/src-tauri/src/app/plugins/rule_runtime.rs b/src-tauri/src/app/plugins/rule_runtime.rs index 1cc3df3c..c2d16d1a 100644 --- a/src-tauri/src/app/plugins/rule_runtime.rs +++ b/src-tauri/src/app/plugins/rule_runtime.rs @@ -1,6 +1,5 @@ //! Usage: Declarative, no-code plugin rule runtime. -use super::privacy_filter::{PrivacyFilter, PrivacyFilterError, PrivacyFilterOptions}; use super::runtime_cache::{runtime_cache_key, RuntimeCacheKeyInput}; use crate::gateway::plugins::context::{ GatewayHookAction, GatewayHookResult, GatewayVisibleHookContext, @@ -196,7 +195,6 @@ impl RuleRuntime { #[derive(Default)] pub(crate) struct RuleRuntimeGatewayPluginExecutor { cache: Mutex>>, - privacy_filter_cache: Mutex>>, } impl RuleRuntimeGatewayPluginExecutor { @@ -211,16 +209,6 @@ impl RuleRuntimeGatewayPluginExecutor { .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() @@ -236,16 +224,6 @@ impl RuleRuntimeGatewayPluginExecutor { .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( @@ -274,34 +252,6 @@ impl RuleRuntimeGatewayPluginExecutor { .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 { @@ -326,30 +276,10 @@ fn rule_runtime_cache_key(plugin: &PluginDetail) -> String { }) } -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: "native:privacyFilter", - }) -} - #[cfg(test)] impl RuleRuntimeGatewayPluginExecutor { - fn cache_sizes_for_tests(&self) -> (usize, usize) { - ( - self.cache.lock().unwrap().len(), - self.privacy_filter_cache.lock().unwrap().len(), - ) + fn cache_size_for_tests(&self) -> usize { + self.cache.lock().unwrap().len() } } @@ -448,453 +378,6 @@ fn load_rule_runtime(plugin: &PluginDetail) -> Result 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()) } @@ -1402,19 +885,6 @@ mod tests { } } - 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(), @@ -1517,59 +987,6 @@ mod tests { 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"); @@ -1595,575 +1012,6 @@ mod tests { .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!({ @@ -2473,11 +1321,11 @@ mod tests { executor .execute_declarative_rules_plugin(&second, context) .expect("second rule runtime loads"); - assert_eq!(executor.cache_sizes_for_tests().0, 2); + assert_eq!(executor.cache_size_for_tests(), 2); executor.retain_runtime_caches_for_plugins(&[first]); - assert_eq!(executor.cache_sizes_for_tests().0, 1); + assert_eq!(executor.cache_size_for_tests(), 1); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src-tauri/src/app/plugins/runtime_executor.rs b/src-tauri/src/app/plugins/runtime_executor.rs index c607f46e..9f49c423 100644 --- a/src-tauri/src/app/plugins/runtime_executor.rs +++ b/src-tauri/src/app/plugins/runtime_executor.rs @@ -1,5 +1,7 @@ //! Usage: Runtime dispatch for gateway plugin execution. +use crate::app::plugins::official_privacy_filter_runtime::OfficialPrivacyFilterRuntime; +use crate::app::plugins::rule_runtime::RuleRuntimeGatewayPluginExecutor; use crate::app::plugins::runtime_manager::{PluginRuntimeManager, RuntimeDispatch}; use crate::app::plugins::runtime_policy::RuntimePolicy; use crate::domain::plugins::PluginDetail; @@ -14,7 +16,8 @@ pub(crate) struct RuntimeExecutionPolicy { #[derive(Default)] pub(crate) struct RuntimeGatewayPluginExecutor { - rule_runtime: crate::app::plugins::rule_runtime::RuleRuntimeGatewayPluginExecutor, + rule_runtime: RuleRuntimeGatewayPluginExecutor, + privacy_filter_runtime: OfficialPrivacyFilterRuntime, policy: RuntimeExecutionPolicy, } @@ -22,8 +25,8 @@ impl RuntimeGatewayPluginExecutor { #[cfg(test)] pub(crate) fn for_tests(policy: RuntimeExecutionPolicy) -> Self { Self { - rule_runtime: - crate::app::plugins::rule_runtime::RuleRuntimeGatewayPluginExecutor::default(), + rule_runtime: RuleRuntimeGatewayPluginExecutor::default(), + privacy_filter_runtime: OfficialPrivacyFilterRuntime::default(), policy, } } @@ -42,9 +45,9 @@ impl RuntimeGatewayPluginExecutor { RuntimeDispatch::DeclarativeRules => self .rule_runtime .execute_declarative_rules_plugin(plugin, context), - RuntimeDispatch::NativePrivacyFilter => self - .rule_runtime - .execute_official_privacy_filter_plugin(plugin, context), + RuntimeDispatch::NativePrivacyFilter => { + self.privacy_filter_runtime.execute_plugin(plugin, context) + } RuntimeDispatch::WasmNotWired => Err(GatewayPluginError::new( "PLUGIN_WASM_NOT_WIRED", "wasm runtime policy is enabled but gateway execution is not wired in this release", @@ -54,6 +57,13 @@ impl RuntimeGatewayPluginExecutor { pub(crate) fn retain_runtime_caches_for_plugins(&self, plugins: &[PluginDetail]) { self.rule_runtime.retain_runtime_caches_for_plugins(plugins); + self.privacy_filter_runtime + .retain_runtime_caches_for_plugins(plugins); + } + + #[cfg(test)] + fn privacy_filter_cache_size_for_tests(&self) -> usize { + self.privacy_filter_runtime.cache_size_for_tests() } } @@ -183,6 +193,25 @@ mod tests { ); } + #[test] + fn runtime_executor_retain_prunes_official_privacy_filter_runtime_cache() { + let executor = executor(); + let plugin = official_privacy_filter_plugin_detail(json!({ + "redactBeforeUpstream": true, + "redactLogs": true + })); + let context = hook_context("log.beforePersist", "trace-privacy"); + + executor + .execute_plugin_sync(&plugin, context) + .expect("official privacy filter runtime executes"); + assert_eq!(executor.privacy_filter_cache_size_for_tests(), 1); + + executor.retain_runtime_caches_for_plugins(&[]); + + assert_eq!(executor.privacy_filter_cache_size_for_tests(), 0); + } + fn executor() -> RuntimeGatewayPluginExecutor { RuntimeGatewayPluginExecutor::for_tests(RuntimeExecutionPolicy { wasm_enabled: false, @@ -228,6 +257,35 @@ mod tests { ) } + fn official_privacy_filter_plugin_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 plugin_detail( plugin_id: &str, runtime: PluginRuntime, From 530682746f4644971a17c5998b3f5a8f74200f45 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 15:48:17 +0800 Subject: [PATCH 016/244] test(plugins): guard hook contract metadata uniqueness --- src-tauri/src/gateway/plugins/contract.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src-tauri/src/gateway/plugins/contract.rs b/src-tauri/src/gateway/plugins/contract.rs index e5b6aca9..19f192ae 100644 --- a/src-tauri/src/gateway/plugins/contract.rs +++ b/src-tauri/src/gateway/plugins/contract.rs @@ -337,6 +337,16 @@ mod tests { .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() @@ -362,6 +372,16 @@ mod tests { .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(); From 214d6163cbb21ae2572261b78ded2568fd24e288 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 15:56:17 +0800 Subject: [PATCH 017/244] test(plugins): cover hook registry contract descriptors --- src-tauri/src/gateway/plugins/registry.rs | 24 ++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/gateway/plugins/registry.rs b/src-tauri/src/gateway/plugins/registry.rs index c27c08a9..89833e70 100644 --- a/src-tauri/src/gateway/plugins/registry.rs +++ b/src-tauri/src/gateway/plugins/registry.rs @@ -56,9 +56,31 @@ impl HookRegistry { mod tests { use super::*; use crate::gateway::plugins::contract::{ - HookKind, DEFAULT_FAILURE_POLICY, DEFAULT_HOOK_TIMEOUT_MS, + 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.id, contract.id); + } + } + + #[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(); From 0379147786d39de2ca0870bac4acda473905bd9f Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 16:02:33 +0800 Subject: [PATCH 018/244] test(plugins): align pipeline defaults with contract --- src-tauri/src/gateway/plugins/pipeline.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src-tauri/src/gateway/plugins/pipeline.rs b/src-tauri/src/gateway/plugins/pipeline.rs index 890698eb..fab8267e 100644 --- a/src-tauri/src/gateway/plugins/pipeline.rs +++ b/src-tauri/src/gateway/plugins/pipeline.rs @@ -1080,11 +1080,20 @@ mod tests { PluginDetail, PluginHook, PluginInstallSource, PluginManifest, PluginRuntime, PluginStatus, PluginSummary, }; + use crate::gateway::plugins::contract::DEFAULT_HOOK_TIMEOUT_MS; use axum::body::Bytes; use axum::http::{HeaderMap, Method}; 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_millis(DEFAULT_HOOK_TIMEOUT_MS) + ); + } + fn plugin(plugin_id: &str, priority: i32, permissions: Vec<&str>) -> PluginDetail { PluginDetail { summary: PluginSummary { From 0ef0919874f2d986584fb8e6dde9c5a419cedfb1 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 16:07:50 +0800 Subject: [PATCH 019/244] test(plugins): reject duplicate hook contract metadata --- scripts/check-plugin-api-contract.mjs | 18 +++++++- .../check-plugin-api-contract.selftest.mjs | 44 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/scripts/check-plugin-api-contract.mjs b/scripts/check-plugin-api-contract.mjs index 4d637f58..4d8fb637 100644 --- a/scripts/check-plugin-api-contract.mjs +++ b/scripts/check-plugin-api-contract.mjs @@ -75,6 +75,16 @@ function requireArray(path, value) { 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 requireOneOf(path, value, allowed) { if (!allowed.includes(value)) { failures.push(`${path} must be one of ${allowed.join(", ")}`); @@ -108,6 +118,8 @@ if (contract) { } 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)) { @@ -126,8 +138,10 @@ if (contract) { } } } - requireArray(`hookMatrix.${hook}.mutationFields`, entry.mutationFields); - requireArray(`hookMatrix.${hook}.contextFields`, entry.contextFields); + 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`); } diff --git a/scripts/check-plugin-api-contract.selftest.mjs b/scripts/check-plugin-api-contract.selftest.mjs index 247df78a..c311c868 100644 --- a/scripts/check-plugin-api-contract.selftest.mjs +++ b/scripts/check-plugin-api-contract.selftest.mjs @@ -228,3 +228,47 @@ if ( `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: ["declarativeRules"], + policyGatedRuntimes: ["wasm"], + officialRuntimes: ["native:privacyFilter"], +}); +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}` + ); +} From 0431be89b71285d66527f1356684075723fe4c4c Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 16:15:26 +0800 Subject: [PATCH 020/244] docs(plugins): guard 0.62 provider api boundary --- docs/plugins/architecture/audit.md | 2 ++ scripts/check-plugin-system-docs.mjs | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/docs/plugins/architecture/audit.md b/docs/plugins/architecture/audit.md index 280748bb..c68c695b 100644 --- a/docs/plugins/architecture/audit.md +++ b/docs/plugins/architecture/audit.md @@ -99,3 +99,5 @@ Bundled official plugin: ## 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/scripts/check-plugin-system-docs.mjs b/scripts/check-plugin-system-docs.mjs index ed46a47f..c4f745ec 100644 --- a/scripts/check-plugin-system-docs.mjs +++ b/scripts/check-plugin-system-docs.mjs @@ -150,6 +150,10 @@ const requiredDocs = [ "native", "信任边界", "性能与稳定性建议", + "0.62 does not add public provider plugin APIs", + ], + caseInsensitivePhrases: [ + "provider adapter facades remain internal", ], }, { @@ -226,6 +230,13 @@ 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}"`); + } + } } if (failures.length > 0) { From 84b5a35442ab8a8b15abaab279e73d6ed9f79c38 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 16:33:57 +0800 Subject: [PATCH 021/244] test(plugins): strengthen hook registry contract mirror --- src-tauri/src/gateway/plugins/registry.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src-tauri/src/gateway/plugins/registry.rs b/src-tauri/src/gateway/plugins/registry.rs index 89833e70..e28c0eee 100644 --- a/src-tauri/src/gateway/plugins/registry.rs +++ b/src-tauri/src/gateway/plugins/registry.rs @@ -71,7 +71,17 @@ mod tests { .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 + ); } } From 7f4e332bee95fc486c391633241c1d5264a48f23 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 19:48:43 +0800 Subject: [PATCH 022/244] fix(updater): open changelog links externally --- src/components/UpdateDialog.tsx | 33 +++++++++++++++--- .../__tests__/UpdateDialog.test.tsx | 34 +++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx index bd736f75..41def67a 100644 --- a/src/components/UpdateDialog.tsx +++ b/src/components/UpdateDialog.tsx @@ -6,6 +6,7 @@ import { quotePlugin, thematicBreakPlugin, } from "@mdxeditor/editor"; +import type { MouseEvent } from "react"; import { toast } from "sonner"; import { AIO_RELEASES_URL } from "../constants/urls"; import { logToConsole } from "../services/consoleLog"; @@ -39,13 +40,34 @@ export function UpdateDialog() { 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 openChangelogLink(url: string) { + try { + await openDesktopUrl(url); + } catch (err) { + logToConsole("error", "打开更新日志链接失败", { error: String(err), url }); + toast("打开链接失败:请查看控制台日志"); + } + } + + function handleChangelogClick(event: MouseEvent) { + const target = event.target; + if (!(target instanceof Element)) return; + + const anchor = target.closest("a[href]"); + if (!anchor) return; + + const href = anchor.getAttribute("href"); + if (!href) return; + + event.preventDefault(); + event.stopPropagation(); + void openChangelogLink(href); + } + async function installUpdate() { if (!updateCandidate) return; if (meta.installingUpdate) return; @@ -121,7 +143,10 @@ export function UpdateDialog() { {updateCandidate?.body ? (
更新日志 -
+
{ 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" }, From 5a105c0a090410e3bd8b7b62acc5f804d5ebef5b Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 20:20:23 +0800 Subject: [PATCH 023/244] fix(plugin-sdk): align permission dependencies with hook contract --- ...ng-hub-0-62-gateway-first-plugin-kernel.md | 144 ++++++++++++++ ...0-62-gateway-first-plugin-kernel-design.md | 2 + packages/plugin-sdk/src/index.test.ts | 20 ++ packages/plugin-sdk/src/index.ts | 30 ++- scripts/check-plugin-api-contract.mjs | 124 +++++++++++- .../check-plugin-api-contract.selftest.mjs | 178 ++++++++++++++++-- 6 files changed, 467 insertions(+), 31 deletions(-) diff --git a/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md b/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md index c1587dbd..a418a105 100644 --- a/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md +++ b/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md @@ -479,6 +479,150 @@ git commit -m "test(plugins): reject duplicate hook contract metadata" Expected: commit succeeds. +## Task 5A: Align SDK Permission Dependencies With Hook Contract + +**Files:** +- Modify: `packages/plugin-sdk/src/index.ts` +- Modify: `packages/plugin-sdk/src/index.test.ts` +- Modify: `scripts/check-plugin-api-contract.mjs` +- Modify: `scripts/check-plugin-api-contract.selftest.mjs` +- Modify: `docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md` +- Modify: `docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md` + +- [x] **Step 1: Write failing SDK tests for host-compatible write-only hooks** + +Add these tests to `packages/plugin-sdk/src/index.test.ts`: + +```ts +it("allows beforeSend request body write-only manifests for host compatibility", () => { + const result = validateManifest({ + ...manifest, + hooks: [{ name: "gateway.request.beforeSend", priority: 10 }], + permissions: ["request.body.write"], + }); + + expect(result).toEqual({ ok: true }); +}); + +it("allows gateway error response body write-only manifests for host compatibility", () => { + const result = validateManifest({ + ...manifest, + hooks: [{ name: "gateway.error", priority: 10 }], + permissions: ["response.body.write"], + }); + + expect(result).toEqual({ ok: true }); +}); +``` + +- [x] **Step 2: Run SDK tests and verify they fail before the fix** + +Run: + +```bash +pnpm --filter @aio-coding-hub/plugin-sdk test +``` + +Expected before implementation: tests fail with `PLUGIN_INVALID_PERMISSION_SET` because SDK applies write/read dependencies globally. + +- [x] **Step 3: Make SDK permission dependencies hook-aware** + +Change `packages/plugin-sdk/src/index.ts` so `validatePermissionSet` receives the full manifest and only applies dependencies for hooks that declare them in Plugin API v1: + +```ts +const permissionSetError = validatePermissionSet(manifest); +``` + +```ts +function validatePermissionSet(manifest: PluginManifest): ValidationResult | null { + 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 invalid( + "PLUGIN_INVALID_PERMISSION_SET", + "request.body.write requires request.body.read" + ); + } + if ( + hooks.has("gateway.response.after") && + set.has("response.body.write") && + !set.has("response.body.read") + ) { + return invalid( + "PLUGIN_INVALID_PERMISSION_SET", + "response.body.write requires response.body.read" + ); + } + if ( + hooks.has("gateway.response.chunk") && + set.has("stream.modify") && + !set.has("stream.inspect") + ) { + return invalid("PLUGIN_INVALID_PERMISSION_SET", "stream.modify requires stream.inspect"); + } + return null; +} +``` + +- [x] **Step 4: Add contract checker self-test for global dependency drift** + +Append a fixture to `scripts/check-plugin-api-contract.selftest.mjs` that creates a temporary SDK with `validatePermissionSet(permissions)` and a global `request.body.write` -> `request.body.read` rule while the contract says `gateway.request.beforeSend.permissionDependencies` is `{}`. + +Run: + +```bash +node scripts/check-plugin-api-contract.selftest.mjs +``` + +Expected before checker implementation: failure because `check-plugin-api-contract.mjs` returns status `0` for the bad SDK fixture. + +- [x] **Step 5: Check SDK permission dependencies from contract metadata** + +Update `scripts/check-plugin-api-contract.mjs` so it derives dependency entries from `hookMatrix.*.permissionDependencies` and checks that SDK validation: + +- calls `validatePermissionSet(manifest)`; +- defines `validatePermissionSet(manifest: PluginManifest)`; +- reads both `manifest.permissions` and `manifest.hooks`; +- guards every dependency behind `hooks.has("")`. + +- [x] **Step 6: Run verification** + +Run: + +```bash +pnpm exec prettier --check packages/plugin-sdk/src/index.ts packages/plugin-sdk/src/index.test.ts scripts/check-plugin-api-contract.mjs scripts/check-plugin-api-contract.selftest.mjs +node scripts/check-plugin-api-contract.selftest.mjs +pnpm check:plugin-api-contract +pnpm --filter @aio-coding-hub/plugin-sdk test +pnpm --filter @aio-coding-hub/plugin-sdk typecheck +pnpm create-aio-plugin:test +pnpm typecheck +git diff --check +``` + +Expected: every command exits `0`; SDK accepts host-compatible write-only hooks and rejects only hook-scoped permission dependency violations. + +- [x] **Step 7: Commit** + +Run: + +```bash +git add packages/plugin-sdk/src/index.ts \ + packages/plugin-sdk/src/index.test.ts \ + scripts/check-plugin-api-contract.mjs \ + scripts/check-plugin-api-contract.selftest.mjs \ + docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md \ + docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md +git commit -m "fix(plugin-sdk): align permission dependencies with hook contract" +``` + +Expected: commit succeeds and contains only the SDK permission dependency alignment plus its contract drift guard. + ## Task 6: Strengthen Provider API Non-Exposure Docs Gate **Files:** diff --git a/docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md b/docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md index 62bd10c2..463b7d00 100644 --- a/docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md +++ b/docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md @@ -104,6 +104,7 @@ Provider 是未来插件系统的重要方向,但 0.62 不开放 Provider Plug - Active/reserved hooks 一致性检查。 - Active/reserved permissions 一致性检查。 - Hook context fields、mutation fields、permission dependencies 一致性检查。 +- SDK manifest validation 必须按 hook-scoped `permissionDependencies` 执行,不能把某个 hook 的 write/read 依赖误提升为全局规则。 - SDK、docs、scaffold、replay、Rust validation 的 drift 报错可定位。 不做: @@ -227,6 +228,7 @@ Provider 是未来插件系统的重要方向,但 0.62 不开放 Provider Plug - Active permissions 与 docs/SDK 一致。 - Reserved permissions 仍被 manifest validation 拒绝。 - Permission dependencies 与 contract 一致。 +- SDK 允许 contract 中无依赖的 write-only hook manifest,同时继续拒绝有依赖的 write-without-read manifest。 - Mutation fields 与 hook descriptor 一致。 - Legacy `contextPatch` 不回到 active contract。 diff --git a/packages/plugin-sdk/src/index.test.ts b/packages/plugin-sdk/src/index.test.ts index 91e29399..76b0fbb0 100644 --- a/packages/plugin-sdk/src/index.test.ts +++ b/packages/plugin-sdk/src/index.test.ts @@ -102,6 +102,26 @@ describe("validateManifest", () => { }); }); + it("allows beforeSend request body write-only manifests for host compatibility", () => { + const result = validateManifest({ + ...manifest, + hooks: [{ name: "gateway.request.beforeSend", priority: 10 }], + permissions: ["request.body.write"], + }); + + expect(result).toEqual({ ok: true }); + }); + + it("allows gateway error response body write-only manifests for host compatibility", () => { + const result = validateManifest({ + ...manifest, + hooks: [{ name: "gateway.error", priority: 10 }], + permissions: ["response.body.write"], + }); + + expect(result).toEqual({ ok: true }); + }); + it("rejects permissions that do not apply to declared hooks", () => { const scopedManifest = { ...manifest, diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 8196c844..b0b9b26b 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -253,7 +253,7 @@ export function validateManifest(manifest: PluginManifest): ValidationResult { return invalid("PLUGIN_UNKNOWN_PERMISSION", `unknown permission: ${permission}`); } } - const permissionSetError = validatePermissionSet(manifest.permissions); + const permissionSetError = validatePermissionSet(manifest); if (permissionSetError) return permissionSetError; const permissionScopeError = validatePermissionScope(manifest); if (permissionScopeError) return permissionScopeError; @@ -286,21 +286,35 @@ function supportsPluginApiV1(value: string): boolean { 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")) { +function validatePermissionSet(manifest: PluginManifest): ValidationResult | null { + 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 invalid( "PLUGIN_INVALID_PERMISSION_SET", "request.body.write requires request.body.read" ); } - if (set.has("response.body.write") && !set.has("response.body.read")) { + if ( + hooks.has("gateway.response.after") && + 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")) { + if ( + hooks.has("gateway.response.chunk") && + set.has("stream.modify") && + !set.has("stream.inspect") + ) { return invalid("PLUGIN_INVALID_PERMISSION_SET", "stream.modify requires stream.inspect"); } return null; @@ -315,7 +329,9 @@ function hookAllowsPermission(hookName: GatewayHookName, permission: PluginPermi permission === "request.body.read" || permission === "request.body.write" ) { - return hookName === "gateway.request.afterBodyRead" || hookName === "gateway.request.beforeSend"; + return ( + hookName === "gateway.request.afterBodyRead" || hookName === "gateway.request.beforeSend" + ); } if ( permission === "response.header.read" || diff --git a/scripts/check-plugin-api-contract.mjs b/scripts/check-plugin-api-contract.mjs index 4d8fb637..78e91023 100644 --- a/scripts/check-plugin-api-contract.mjs +++ b/scripts/check-plugin-api-contract.mjs @@ -59,6 +59,91 @@ function requireRegex(path, text, regex, label) { } } +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +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 dependencyEntries(contract, matrix) { + const entries = []; + for (const hook of contract.activeHooks ?? []) { + const hookContract = matrix?.[hook]; + if (hookContract == null || typeof hookContract !== "object" || Array.isArray(hookContract)) { + continue; + } + const dependencies = hookContract.permissionDependencies; + if (dependencies == null || typeof dependencies !== "object" || Array.isArray(dependencies)) { + continue; + } + for (const [permission, requiredPermissions] of Object.entries(dependencies)) { + if (!Array.isArray(requiredPermissions)) continue; + for (const requiredPermission of requiredPermissions) { + entries.push({ hook, permission, requiredPermission }); + } + } + } + return entries; +} + +function requireSdkPermissionDependencyContract(path, text, contract, matrix) { + if (!/validatePermissionSet\s*\(\s*manifest\s*:\s*PluginManifest\b/.test(text)) { + failures.push(`${path} validatePermissionSet must accept PluginManifest`); + } + if (!/validatePermissionSet\s*\(\s*manifest\s*\)/.test(text)) { + failures.push(`${path} validateManifest must pass manifest to validatePermissionSet`); + } + + const body = functionBody(text, "validatePermissionSet"); + if (body == null) { + failures.push(`${path} is missing validatePermissionSet body`); + return; + } + requireIncludes( + path, + body, + ["manifest.permissions", "manifest.hooks"], + "hook-aware permission validation" + ); + + for (const { hook, permission, requiredPermission } of dependencyEntries(contract, matrix)) { + const hookGuard = new RegExp(`hooks\\.has\\(\\s*["']${escapeRegex(hook)}["']\\s*\\)`, "g"); + let guarded = false; + let match = hookGuard.exec(body); + while (match) { + const window = body.slice(match.index, match.index + 700); + if (window.includes(permission) && window.includes(requiredPermission)) { + guarded = true; + break; + } + match = hookGuard.exec(body); + } + if (!guarded) { + failures.push( + `${path} validatePermissionSet must guard ${permission} requires ${requiredPermission} behind ${hook}` + ); + } + } +} + function requireObject(path, value) { if (value == null || typeof value !== "object" || Array.isArray(value)) { failures.push(`${path} must be an object`); @@ -116,15 +201,24 @@ if (contract) { 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); + 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) ?? {}; + 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`); + failures.push( + `hookMatrix.${hook}.permissionDependencies.${permission} must be a write permission` + ); } const requiredPermissions = requireArray( `hookMatrix.${hook}.permissionDependencies.${permission}`, @@ -175,6 +269,7 @@ if (contract) { contract.activeMutationFields ?? [], "active mutation field" ); + requireSdkPermissionDependencyContract("packages/plugin-sdk/src/index.ts", sdk, contract, matrix); const scaffold = readText("packages/create-aio-plugin/src/scaffold.ts"); requireIncludes( @@ -255,7 +350,12 @@ if (contract) { 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, @@ -300,7 +400,12 @@ if (contract) { const wasmGuidePath = "docs/plugins/runtime/wasm.md"; const wasmGuide = readText(wasmGuidePath); - requireIncludes(wasmGuidePath, wasmGuide, ["wasm", "PLUGIN_RUNTIME_DISABLED"], "WASM policy token"); + requireIncludes( + wasmGuidePath, + wasmGuide, + ["wasm", "PLUGIN_RUNTIME_DISABLED"], + "WASM policy token" + ); requireRegex( "packages/plugin-sdk/src/index.ts", @@ -332,7 +437,12 @@ if (contract) { ["PLUGIN_RESERVED_HOOK", "PLUGIN_RESERVED_PERMISSION"], "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, diff --git a/scripts/check-plugin-api-contract.selftest.mjs b/scripts/check-plugin-api-contract.selftest.mjs index c311c868..eb5220f6 100644 --- a/scripts/check-plugin-api-contract.selftest.mjs +++ b/scripts/check-plugin-api-contract.selftest.mjs @@ -32,23 +32,70 @@ function writePassingScaffold(root) { writeFileSync( join(root, "packages/plugin-sdk/src/index.ts"), [ - "gateway.request.afterBodyRead gateway.response.headers", - "request.body.read request.body.write network.fetch requestBody", + [ + "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", "declarativeRules wasm", - "export type ActiveGatewayHookName = 'gateway.request.afterBodyRead';", + [ + "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;", + "}", ].join("\n") ); writeFileSync( join(root, "packages/create-aio-plugin/src/scaffold.ts"), - "declarativeRules wasm gateway.request.afterBodyRead request.body.read request.body.write" + [ + "declarativeRules wasm gateway.request.afterBodyRead gateway.request.beforeSend", + "request.body.read request.body.write", + ].join("\n") ); writeFileSync( join(root, "src-tauri/src/gateway/plugins/contract.rs"), [ - "gateway.request.afterBodyRead gateway.response.headers", - "request.body.read network.fetch", + "gateway.request.afterBodyRead gateway.request.beforeSend gateway.response.headers", + "request.body.read request.body.write network.fetch", ].join("\n") ); writeFileSync( @@ -59,10 +106,12 @@ function writePassingScaffold(root) { "crate::gateway::plugins::contract::is_reserved_hook", "crate::gateway::plugins::contract::is_reserved_permission", "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\" }", - "pub fn is_reserved_permission(permission: &str) -> bool { permission == \"network.fetch\" }", - "fn permission_risk(permission: &str) { request.body.read; network.fetch; }", + "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" }', + 'pub fn is_reserved_permission(permission: &str) -> bool { permission == "network.fetch" }', + "fn permission_risk(permission: &str) { request.body.read; request.body.write; network.fetch; }", "PLUGIN_RESERVED_HOOK PLUGIN_RESERVED_PERMISSION", ].join("\n") ); @@ -72,15 +121,18 @@ function writePassingScaffold(root) { ); writeFileSync( join(root, "docs/plugin-manifest-v1.md"), - "gateway.request.afterBodyRead gateway.response.headers request.body.read network.fetch" + [ + "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.response.headers" + "gateway.request.afterBodyRead gateway.request.beforeSend gateway.response.headers" ); writeFileSync( join(root, "docs/plugins/reference/permissions.md"), - "request.body.read network.fetch" + "request.body.read request.body.write network.fetch" ); writeFileSync( join(root, "docs/plugins/reference/manifest.md"), @@ -124,16 +176,25 @@ 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/hooks.md"), + "gateway.request.afterBodyRead" +); writeFileSync(join(reservedHookRoot, "docs/plugins/reference/permissions.md"), "request.body.read"); writeFileSync( join(reservedHookRoot, "docs/plugins/reference/manifest.md"), "declarativeRules wasm native privacyFilter" ); -writeFileSync(join(reservedHookRoot, "docs/plugins/runtime/wasm.md"), "wasm PLUGIN_RUNTIME_DISABLED"); +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")) { +if ( + reservedHookResult.status === 0 || + !reservedHookResult.stderr.includes("gateway.response.headers") +) { throw new Error( `expected structural contract failure, got status ${reservedHookResult.status}\n${reservedHookResult.stderr}` ); @@ -173,7 +234,9 @@ if ( !missingHookMetadataResult.stderr.includes( "hookMatrix.gateway.request.afterBodyRead.permissionDependencies" ) || - !missingHookMetadataResult.stderr.includes("hookMatrix.gateway.request.afterBodyRead.mutationFields") + !missingHookMetadataResult.stderr.includes( + "hookMatrix.gateway.request.afterBodyRead.mutationFields" + ) ) { throw new Error( `expected hookMatrix metadata failure, got status ${missingHookMetadataResult.status}\n${missingHookMetadataResult.stderr}` @@ -272,3 +335,84 @@ if ( `expected hookMatrix duplicate metadata failure, got status ${duplicateHookMetadataResult.status}\n${duplicateHookMetadataResult.stderr}` ); } + +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: ["declarativeRules"], + policyGatedRuntimes: ["wasm"], + officialRuntimes: ["native:privacyFilter"], +}); +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 = 'declarativeRules 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") +); + +const globalPermissionDependencyResult = runCheck(globalPermissionDependencyRoot); +if ( + globalPermissionDependencyResult.status === 0 || + !globalPermissionDependencyResult.stderr.includes( + "packages/plugin-sdk/src/index.ts validatePermissionSet must accept PluginManifest" + ) || + !globalPermissionDependencyResult.stderr.includes("gateway.request.afterBodyRead") +) { + throw new Error( + `expected hook-aware permission dependency failure, got status ${globalPermissionDependencyResult.status}\n${globalPermissionDependencyResult.stderr}` + ); +} From adb9ceda5886fd25acddb916701a6d0d3a4dd06a Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 20:27:53 +0800 Subject: [PATCH 024/244] docs(plugins): record 0.62 gateway-first verification --- ...ng-hub-0-62-gateway-first-plugin-kernel.md | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md b/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md index a418a105..06e712c5 100644 --- a/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md +++ b/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md @@ -698,7 +698,7 @@ Expected: commit succeeds. - Read: `docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md` - Read: `docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md` -- [ ] **Step 1: Verify no public plugin API files changed unexpectedly** +- [x] **Step 1: Verify no public plugin API files changed unexpectedly** Run: @@ -713,7 +713,7 @@ Inspect the output. Expected for this plan: - No `plugin.json` schema shape changes. - No public provider plugin API docs. -- [ ] **Step 2: Run Rust formatting and compile gates** +- [x] **Step 2: Run Rust formatting and compile gates** Run: @@ -725,7 +725,7 @@ cd src-tauri && RUSTFLAGS=-Dwarnings cargo check --locked Expected: all commands exit `0`. -- [ ] **Step 3: Run plugin-focused Rust tests** +- [x] **Step 3: Run plugin-focused Rust tests** Run: @@ -735,7 +735,7 @@ cd src-tauri && cargo test plugin --lib Expected: all plugin tests pass, with existing performance smoke tests still ignored. -- [ ] **Step 4: Run provider-focused Rust tests** +- [x] **Step 4: Run provider-focused Rust tests** Run: @@ -745,7 +745,7 @@ cd src-tauri && cargo test provider --lib Expected: all provider tests pass. -- [ ] **Step 5: Run targeted gateway hook tests** +- [x] **Step 5: Run targeted gateway hook tests** Run: @@ -758,7 +758,7 @@ cd src-tauri && cargo test plugin_log_redaction --lib Expected: all matching tests pass. -- [ ] **Step 6: Run contract and docs gates** +- [x] **Step 6: Run contract and docs gates** Run: @@ -770,7 +770,7 @@ node scripts/check-plugin-api-contract.selftest.mjs Expected: all commands exit `0`. -- [ ] **Step 7: Commit final verification notes if docs changed** +- [x] **Step 7: Commit final verification notes if docs changed** If verification requires a small documentation note, commit it: @@ -782,7 +782,7 @@ git commit -m "docs(plugins): record 0.62 gateway-first verification" If no files changed, do not create an empty commit. -- [ ] **Step 8: Report final status** +- [x] **Step 8: Report final status** Report: @@ -791,3 +791,38 @@ Report: - any ignored performance smoke tests, - confirmation that Plugin API v1 remains externally compatible, - confirmation that Provider Plugin API was not opened. + +### Verification Run: 2026-06-22 + +Step 1 review of `git diff --name-only b53075ba..HEAD` showed this branch contains both 0.62 plugin-kernel work and the merged provider route-order work from `origin/main`. Public plugin API changes are limited to the deliberate SDK contract-alignment fix in `packages/plugin-sdk`; no `packages/create-aio-plugin` changes, no `plugin.json` schema shape changes, and no public provider plugin API docs were introduced. + +Commands run: + +```bash +cd src-tauri && cargo fmt -- --check && cargo check --locked && RUSTFLAGS=-Dwarnings cargo check --locked +cd src-tauri && cargo test plugin --lib +cd src-tauri && cargo test provider --lib +cd src-tauri && cargo test gateway_plugin_pipeline --lib +cd src-tauri && cargo test gateway_plugin_request --lib +cd src-tauri && cargo test gateway_plugin_response --lib +cd src-tauri && cargo test plugin_log_redaction --lib +pnpm check:plugin-api-contract +pnpm check:plugin-system-docs +node scripts/check-plugin-api-contract.selftest.mjs +``` + +Observed results: + +- Rust formatting and compile gates exited `0`. +- `cargo test plugin --lib`: `180 passed; 0 failed; 2 ignored; 1296 filtered out`. The ignored tests are the existing performance smoke tests `perf_empty_pipeline_request_hook_budget` and `perf_one_noop_plugin_request_hook_budget`. +- `cargo test provider --lib`: `219 passed; 0 failed; 0 ignored; 1259 filtered out`. +- `cargo test gateway_plugin_pipeline --lib`: `9 passed; 0 failed`. +- `cargo test gateway_plugin_request --lib`: `4 passed; 0 failed`. +- `cargo test gateway_plugin_response --lib`: `5 passed; 0 failed`. +- `cargo test plugin_log_redaction --lib`: `1 passed; 0 failed`. +- Contract and docs gates exited `0`. + +Compatibility conclusions: + +- Plugin API v1 remains externally compatible for this plan scope. +- Provider Plugin API remains closed; provider adapter facades are still internal. From 32edb7de836263f2959ffc6f6789bb71f247806d Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 20:32:30 +0800 Subject: [PATCH 025/244] docs(plugins): record plugin performance smoke --- ...26-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md b/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md index 06e712c5..db7b9884 100644 --- a/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md +++ b/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md @@ -809,6 +809,7 @@ cd src-tauri && cargo test plugin_log_redaction --lib pnpm check:plugin-api-contract pnpm check:plugin-system-docs node scripts/check-plugin-api-contract.selftest.mjs +pnpm plugin:perf-smoke ``` Observed results: @@ -821,8 +822,10 @@ Observed results: - `cargo test gateway_plugin_response --lib`: `5 passed; 0 failed`. - `cargo test plugin_log_redaction --lib`: `1 passed; 0 failed`. - Contract and docs gates exited `0`. +- `pnpm plugin:perf-smoke`: `2 passed; 0 failed`; observed `perf_empty_pipeline_request_hook_budget` average `961ns` against the `25us` budget, and `perf_one_noop_plugin_request_hook_budget` average `4505ns` against the `250us` budget. Compatibility conclusions: - Plugin API v1 remains externally compatible for this plan scope. - Provider Plugin API remains closed; provider adapter facades are still internal. +- Performance smoke did not show gateway hot path regression for the measured plugin pipeline budgets. From 59537f8816aed6b43b8c60244f1fa3981220f250 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 20:40:15 +0800 Subject: [PATCH 026/244] docs(plugins): complete 0.62 gateway-first audit --- ...ng-hub-0-62-gateway-first-plugin-kernel.md | 75 +++++++++++-------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md b/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md index db7b9884..97fa774c 100644 --- a/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md +++ b/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel.md @@ -45,7 +45,7 @@ - Modify: `src-tauri/src/app/plugins/rule_runtime.rs` - Modify: `src-tauri/src/app/plugins/runtime_executor.rs` -- [ ] **Step 1: Confirm the regression test exists** +- [x] **Step 1: Confirm the regression test exists** Open `src-tauri/src/app/plugins/runtime_executor.rs` and confirm the test module contains this test: @@ -70,7 +70,7 @@ fn runtime_executor_retain_prunes_official_privacy_filter_runtime_cache() { } ``` -- [ ] **Step 2: Verify the regression test passes against the current implementation** +- [x] **Step 2: Verify the regression test passes against the current implementation** Run: @@ -80,7 +80,7 @@ cd src-tauri && cargo test runtime_executor_retain_prunes_official_privacy_filte Expected: `1 passed; 0 failed`. -- [ ] **Step 3: Confirm runtime ownership boundaries in code** +- [x] **Step 3: Confirm runtime ownership boundaries in code** Confirm `src-tauri/src/app/plugins/rule_runtime.rs` contains no privacy-filter ownership: @@ -98,7 +98,7 @@ RuntimeDispatch::NativePrivacyFilter => { } ``` -- [ ] **Step 4: Run focused behavior checks** +- [x] **Step 4: Run focused behavior checks** Run: @@ -112,7 +112,7 @@ Expected: - `official_privacy_filter --lib`: `32 passed; 0 failed` - `rule_runtime_prunes_cache_entries_not_in_active_plugin_keys --lib`: `1 passed; 0 failed` -- [ ] **Step 5: Run compile and formatting checks** +- [x] **Step 5: Run compile and formatting checks** Run: @@ -124,7 +124,7 @@ cd src-tauri && RUSTFLAGS=-Dwarnings cargo check --locked Expected: all commands exit `0`. -- [ ] **Step 6: Commit the runtime split** +- [x] **Step 6: Commit the runtime split** Run: @@ -143,7 +143,7 @@ Expected: commit succeeds and contains only the runtime split. **Files:** - Modify: `src-tauri/src/gateway/plugins/contract.rs` -- [ ] **Step 1: Write the failing duplicate-field test** +- [x] **Step 1: Write the failing duplicate-field test** Append this helper and test inside the existing `#[cfg(test)] mod tests` in `src-tauri/src/gateway/plugins/contract.rs`: @@ -169,7 +169,7 @@ fn hook_contract_arrays_do_not_contain_duplicates() { } ``` -- [ ] **Step 2: Run the test and verify it fails for the current duplicate** +- [x] **Step 2: Run the test and verify it fails for the current duplicate** Run: @@ -183,7 +183,7 @@ Expected before the fix: failure containing: gateway.request.beforeSend context_fields contains duplicate value request.query ``` -- [ ] **Step 3: Fix the duplicate context field** +- [x] **Step 3: Fix the duplicate context field** In `src-tauri/src/gateway/plugins/contract.rs`, update the `gateway.request.beforeSend` `context_fields` array from: @@ -218,7 +218,7 @@ context_fields: &[ ], ``` -- [ ] **Step 4: Run the test and verify it passes** +- [x] **Step 4: Run the test and verify it passes** Run: @@ -228,7 +228,7 @@ cd src-tauri && cargo test hook_contract_arrays_do_not_contain_duplicates --lib Expected: `1 passed; 0 failed`. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** Run: @@ -244,7 +244,7 @@ Expected: commit succeeds. **Files:** - Modify: `src-tauri/src/gateway/plugins/registry.rs` -- [ ] **Step 1: Add a descriptor mirror test** +- [x] **Step 1: Add a descriptor mirror test** Inside `#[cfg(test)] mod tests` in `src-tauri/src/gateway/plugins/registry.rs`, add this test: @@ -277,7 +277,7 @@ fn registry_descriptors_mirror_every_hook_contract() { } ``` -- [ ] **Step 2: Run the focused test** +- [x] **Step 2: Run the focused test** Run: @@ -287,7 +287,7 @@ cd src-tauri && cargo test registry_descriptors_mirror_every_hook_contract --lib Expected: `1 passed; 0 failed`. -- [ ] **Step 3: Run existing registry tests** +- [x] **Step 3: Run existing registry tests** Run: @@ -297,7 +297,7 @@ cd src-tauri && cargo test gateway::plugins::registry --lib Expected: all registry tests pass. -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** Run: @@ -313,7 +313,7 @@ Expected: commit succeeds. **Files:** - Modify: `src-tauri/src/gateway/plugins/pipeline.rs` -- [ ] **Step 1: Add the default timeout alignment test** +- [x] **Step 1: Add the default timeout alignment test** Inside the existing `#[cfg(test)] mod tests` in `src-tauri/src/gateway/plugins/pipeline.rs`, add: @@ -329,7 +329,7 @@ fn default_pipeline_timeout_matches_plugin_contract() { } ``` -- [ ] **Step 2: Run the focused test** +- [x] **Step 2: Run the focused test** Run: @@ -339,7 +339,7 @@ cd src-tauri && cargo test default_pipeline_timeout_matches_plugin_contract --li Expected: `1 passed; 0 failed`. -- [ ] **Step 3: Run pipeline plugin tests** +- [x] **Step 3: Run pipeline plugin tests** Run: @@ -349,7 +349,7 @@ cd src-tauri && cargo test gateway_plugin_pipeline --lib Expected: all matching pipeline tests pass, with existing performance smoke tests still ignored. -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** Run: @@ -366,7 +366,7 @@ Expected: commit succeeds. - Modify: `scripts/check-plugin-api-contract.mjs` - Modify: `scripts/check-plugin-api-contract.selftest.mjs` -- [ ] **Step 1: Add the failing self-test fixture** +- [x] **Step 1: Add the failing self-test fixture** In `scripts/check-plugin-api-contract.selftest.mjs`, append this fixture near the other hook metadata fixtures: @@ -416,7 +416,7 @@ if ( } ``` -- [ ] **Step 2: Run the self-test and verify it fails** +- [x] **Step 2: Run the self-test and verify it fails** Run: @@ -426,7 +426,7 @@ node scripts/check-plugin-api-contract.selftest.mjs Expected before implementation: failure because the checker does not yet report duplicate `readPermissions`. -- [ ] **Step 3: Implement duplicate array detection** +- [x] **Step 3: Implement duplicate array detection** In `scripts/check-plugin-api-contract.mjs`, add this helper after `requireArray`: @@ -457,7 +457,7 @@ requireUniqueArray(`hookMatrix.${hook}.contextFields`, contextFields); Keep the existing permission dependency checks using `readPermissions` and `writePermissions`. -- [ ] **Step 4: Run contract checks** +- [x] **Step 4: Run contract checks** Run: @@ -468,7 +468,7 @@ node scripts/check-plugin-api-contract.selftest.mjs Expected: both commands exit `0`. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** Run: @@ -630,7 +630,7 @@ Expected: commit succeeds and contains only the SDK permission dependency alignm - Modify: `docs/plugins/reference/compatibility.md` - Modify: `scripts/check-plugin-system-docs.mjs` -- [ ] **Step 1: Add required phrases to docs check** +- [x] **Step 1: Add required phrases to docs check** In `scripts/check-plugin-system-docs.mjs`, extend the `docs/plugins/architecture/audit.md` entry so its `phrases` array includes: @@ -641,7 +641,7 @@ In `scripts/check-plugin-system-docs.mjs`, extend the `docs/plugins/architecture The entry should still include the existing phrases for `official.privacy-filter`, `declarativeRules`, `WASM`, `native`, `信任边界`, and `性能与稳定性建议`. -- [ ] **Step 2: Run docs check and verify it fails** +- [x] **Step 2: Run docs check and verify it fails** Run: @@ -651,7 +651,7 @@ pnpm check:plugin-system-docs Expected before doc updates: failure pointing at the missing provider API phrases in `docs/plugins/architecture/audit.md`. -- [ ] **Step 3: Update architecture audit** +- [x] **Step 3: Update architecture audit** In `docs/plugins/architecture/audit.md`, extend the `0.62 Platform Kernel Decision` section with this paragraph: @@ -659,7 +659,7 @@ In `docs/plugins/architecture/audit.md`, extend the `0.62 Platform Kernel Decisi 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. ``` -- [ ] **Step 4: Confirm compatibility doc still has the public API boundary** +- [x] **Step 4: Confirm compatibility doc still has the public API boundary** Ensure `docs/plugins/reference/compatibility.md` contains: @@ -671,7 +671,7 @@ Plugin API v1 remains externally compatible in 0.62 If any line is missing, add it to the 0.62 compatibility section. -- [ ] **Step 5: Run docs check** +- [x] **Step 5: Run docs check** Run: @@ -681,7 +681,7 @@ pnpm check:plugin-system-docs Expected: command exits `0`. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** Run: @@ -829,3 +829,18 @@ Compatibility conclusions: - Plugin API v1 remains externally compatible for this plan scope. - Provider Plugin API remains closed; provider adapter facades are still internal. - Performance smoke did not show gateway hot path regression for the measured plugin pipeline budgets. + +### Completion Audit: 2026-06-22 + +Requirement-by-requirement evidence: + +- **Document 0.62 goals and architecture:** covered by `docs/superpowers/specs/2026-06-21-aio-coding-hub-0-62-plugin-platform-kernel-design.md` and `docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-gateway-first-plugin-kernel-design.md`. +- **Preserve Plugin API v1 external compatibility:** verified by the Task 7 diff review, `pnpm check:plugin-api-contract`, SDK tests, and the SDK hook-scoped permission dependency fix in `5a105c0a`. +- **Contract layer drift detection:** covered by `0ef09198`, `5a105c0a`, `pnpm check:plugin-api-contract`, and `node scripts/check-plugin-api-contract.selftest.mjs`. +- **Hook registry and descriptor alignment:** covered by `53068274`, `214d6163`, `03791477`, `84b5a354`, `cargo test plugin --lib`, and targeted gateway hook tests. +- **Runtime ownership clarity:** covered by `3b8ed702`, runtime-focused plugin tests, and `cargo test plugin --lib`. +- **Provider adapter remains internal:** covered by `773992b2`, `d7d54ed1`, `0431be89`, `cargo test provider --lib`, and `pnpm check:plugin-system-docs`. +- **Frontend/backend boundary remains host-owned for plugin execution:** covered by the spec non-goals and the absence of public provider plugin API or WebView/JS runtime changes in the Task 7 diff review. +- **Release verification gates:** covered by the 2026-06-22 verification run, `pnpm plugin:perf-smoke`, and the pushed branch pre-push checks. + +Final status for this plan: the 0.62 Gateway-first plugin kernel plan is implemented and verified on branch `codex/plugin-platform-kernel-0-62`. Future work should be tracked as new 0.62 follow-up specs or post-0.62 provider/plugin API RFCs rather than extending this completed plan. From 7d9802422cd3648df020240015ff3f501836e819 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 22:53:17 +0800 Subject: [PATCH 027/244] docs(plugins): design 0.62.1 developer loop --- ...hub-0-62-1-plugin-developer-loop-design.md | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-1-plugin-developer-loop-design.md diff --git a/docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-1-plugin-developer-loop-design.md b/docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-1-plugin-developer-loop-design.md new file mode 100644 index 00000000..48b70dd8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-aio-coding-hub-0-62-1-plugin-developer-loop-design.md @@ -0,0 +1,310 @@ +# aio-coding-hub 0.62.1 Plugin Developer Loop and Observability Design + +## Summary + +0.62.1 is the plugin developer-loop and observability release. 0.62.0 stabilized the gateway-first plugin kernel while keeping Plugin API v1 externally compatible and Provider Plugin API private. 0.62.1 keeps those boundaries and makes the existing plugin system easier to develop, validate, replay, diagnose, and maintain. + +The main goal is not to add more public plugin power. The main goal is to make the current Plugin API v1 trustworthy in day-to-day use: a plugin author can scaffold, diagnose, strictly validate, replay with explanations, pack, install, enable, and then inspect runtime behavior from the desktop GUI. + +## Current State + +The current project already has the right foundations: + +- `packages/create-aio-plugin/src/devtools.ts` provides `validate`, `replay`, `pack`, `sign`, and `verify`. +- `packages/create-aio-plugin/src/scaffold.ts` scaffolds declarative rules and experimental WASM plugin templates. +- `packages/plugin-sdk/src/index.ts` validates Plugin API v1 manifests and hook-scoped permission dependencies. +- `src-tauri/src/domain/plugins.rs` validates manifests against the Rust contract metadata and exposes `PluginDetail.audit_logs` plus `PluginDetail.runtime_failures`. +- `src-tauri/src/gateway/plugins/audit.rs` persists gateway plugin audit events and runtime failures. +- `src-tauri/src/commands/plugins.rs` exposes plugin management commands and `plugin_list_audit_logs`. +- `src/pages/PluginsPage.tsx` already shows plugin details, permissions, config, hooks, and a small audit preview. +- `src-tauri/src/gateway/proxy/protocol_bridge/*` and provider-related gateway modules already provide internal provider adapter and bridge foundations. + +The main gaps are operational: + +- `create-aio-plugin validate` checks the manifest but does not deeply diagnose plugin package health. +- `create-aio-plugin replay` is a lightweight TypeScript declarative-rules simulator and does not explain enough for users to debug rules. +- The GUI has audit data, but it does not yet present runtime failures and hook-level behavior as a clear troubleshooting surface. +- Rust contract metadata, SDK metadata, docs, and scaffolds are still partly hand-maintained. +- Provider-specific behavior has internal adapter foundations, but needs more acceptance tests before any future provider plugin RFC is considered. + +## Goals + +0.62.1 must deliver: + +1. A stronger local plugin development loop. +2. Better plugin runtime observability in the Tauri desktop GUI. +3. Better declarative-rules explainability without changing Plugin API v1. +4. A first stage of contract-driven metadata generation or drift prevention across Rust, SDK, docs, and scaffold. +5. Internal provider adapter acceptance coverage without opening Provider Plugin API. + +## Non-Goals + +0.62.1 does not: + +- change the public Plugin API v1 manifest shape; +- introduce Plugin API v2; +- expose Provider Plugin API; +- expose JS or WebView plugin runtime; +- enable arbitrary marketplace WASM execution by default; +- let plugins directly control provider selection, failover, OAuth, token counting, or session binding; +- build a browser-like plugin container inside aio-coding-hub; +- build a full plugin marketplace product experience. + +## Product Direction + +The target user experience is: + +```text +create-aio-plugin acme.redactor +create-aio-plugin doctor ./acme.redactor +create-aio-plugin validate --strict ./acme.redactor +create-aio-plugin replay --explain ./acme.redactor fixtures/request.json gateway.request.afterBodyRead +create-aio-plugin pack ./acme.redactor +``` + +Then in the desktop app: + +1. Import the `.aio-plugin` package. +2. Grant permissions and enable the plugin. +3. Send a request through the gateway. +4. Open the plugin detail view. +5. See recent runtime failures, audit events, hook names, event types, failure kinds, trace IDs, and concise mutation summaries. + +This release should make it clear whether a plugin did nothing, matched a rule, changed a body/header/chunk/log, warned, blocked, failed open, failed closed, timed out, or tripped a circuit. + +## Architecture + +0.62.1 uses four layers. + +### 1. Contract Source + +Plugin API v1 metadata remains the source of truth for hook names, hook status, permissions, mutation fields, permission dependencies, failure policy defaults, timeout defaults, and runtime availability. + +Implementation may either generate TypeScript/docs/scaffold metadata from the contract or strengthen checkers that prove they remain aligned. The important invariant is that future hook or permission changes cannot silently drift between Rust, SDK, docs, and scaffold. + +### 2. Developer Tools + +`packages/create-aio-plugin` becomes the local diagnostic surface. + +`doctor` reports structured diagnostics: + +- `severity`: `error`, `warn`, or `info`; +- `code`: stable machine-readable code; +- `message`: short human-readable explanation; +- `path`: file or JSON path when applicable; +- `hint`: actionable next step. + +`validate --strict` preserves the existing success/failure behavior while adding package-level checks: + +- `plugin.json` exists and parses; +- manifest passes SDK validation; +- declarative rules files exist; +- declarative rule documents parse; +- rule hooks are declared in manifest hooks; +- rule targets are compatible with the hook; +- requested permissions apply to at least one declared hook; +- runtime policy limitations are reported clearly for WASM and native runtimes. + +`replay --explain` continues to support declarative-rules fixtures and returns an explanation model instead of only the final action: + +- input hook; +- plugin id and runtime; +- evaluated rule count; +- matched rule ids; +- target field and JSON path; +- action kind; +- output kind: `pass`, `replace`, `block`, `warn`; +- mutation summary without storing or printing full sensitive payloads by default; +- warnings for unsupported replay constructs. + +### 3. Host Observability + +The Rust gateway remains the authority for real runtime behavior. It already persists audit events and runtime failures. 0.62.1 should expose that data more clearly through existing command/query patterns. + +The plugin detail panel should add: + +- a runtime status summary; +- runtime failures grouped by hook and failure kind; +- audit logs grouped or labeled by hook, event type, risk, and trace ID; +- copyable trace IDs where available; +- refresh behavior that invalidates plugin detail and audit queries; +- empty states that explain when no hook has run yet. + +If implemented, clearing audit logs or runtime failures must be a secondary action and should not be required for 0.62.1 acceptance. + +### 4. Provider Internal Boundary + +Provider adapter and protocol bridge work stays internal. 0.62.1 should add tests around host-owned behavior: + +- route ordering; +- provider failover; +- OAuth limit snapshots; +- token counting; +- session binding; +- cx2cc protocol bridge request and response translation; +- provider-specific request preparation. + +These tests are not a public provider plugin API. They make future provider extension work safer by proving current behavior before any separate RFC. + +## Functional Scope + +### Required + +- Add `create-aio-plugin doctor`. +- Add `create-aio-plugin validate --strict`. +- Add `create-aio-plugin replay --explain`. +- Improve declarative-rules diagnostics and replay explanation. +- Improve plugin detail observability in the GUI. +- Add or strengthen contract drift gates for SDK/docs/scaffold metadata. +- Add provider internal acceptance tests. + +### Optional + +- Add GUI actions to clear runtime failures or audit logs. +- Add `replay --fixture-from-log `. +- Add richer rule examples beyond the minimal fixtures. +- Generate documentation tables directly from the contract. + +### Deferred + +- WASM explain/replay parity. +- Marketplace productization. +- Provider Plugin API. +- Plugin API v2. +- JS/WebView runtime. + +## Data Flow + +### Local Development + +```text +plugin directory + -> create-aio-plugin doctor + -> create-aio-plugin validate --strict + -> create-aio-plugin replay --explain + -> create-aio-plugin pack + -> .aio-plugin package +``` + +`doctor` and `validate --strict` use SDK validation plus package-level file and rule checks. `replay --explain` uses the declarative-rules replay model and emits a stable explanation JSON shape. + +### Runtime Observability + +```text +gateway hook execution + -> GatewayPluginAuditEvent + -> plugin_audit_logs / plugin_runtime_failures + -> plugin_get / plugin_list_audit_logs + -> React Query + -> PluginsPage detail panel +``` + +The GUI should treat persisted audit data as runtime evidence and not infer sensitive details from raw request or response bodies. + +## Error Handling + +Developer tools should prefer stable diagnostic codes over free-form text. A command with any `error` severity diagnostics should exit non-zero. Warnings keep exit code zero in 0.62.1; warning-as-error behavior is outside this spec and would require a separate flag and acceptance criteria. + +Runtime observability should handle missing audit tables the same way existing plugin loading repairs missing plugin schema: fail softly when possible and show a readable UI error state when recovery is not possible. + +Replay explain must clearly distinguish: + +- unsupported runtime; +- unsupported declarative rule shape; +- rule did not match; +- rule matched but produced no mutation; +- rule matched and produced a mutation; +- invalid fixture shape. + +## Compatibility + +Plugin API v1 remains externally compatible. Existing plugin manifests that validate today should continue to validate unless they rely on behavior that the current host already rejects. `validate --strict` may report additional warnings or errors for package-level defects such as missing rule files, but it must not redefine the public manifest contract. + +Provider Plugin API remains private. Any provider adapter changes are internal refactors or tests. + +WASM remains policy-gated. The WASM scaffold can still exist, but 0.62.1 does not enable arbitrary WASM marketplace execution. + +## Testing Strategy + +### TypeScript Unit Tests + +`packages/create-aio-plugin/src/scaffold.test.ts` or new focused tests should cover: + +- `doctor` reports missing `plugin.json`; +- `doctor` reports missing declarative rule files; +- `validate --strict` rejects unknown hooks and permission mismatch; +- `validate --strict` rejects malformed rule documents; +- `replay --explain` reports pass when no rule matches; +- `replay --explain` reports matched rule id and replace summary; +- `replay --explain` reports block and warn actions; +- pack still preserves binary-safe behavior. + +`packages/plugin-sdk/src/index.test.ts` should continue to prove Plugin API v1 compatibility and hook-scoped permission dependencies. + +### Rust Tests + +Gateway/plugin tests should cover: + +- audit events still persist for hook failures; +- runtime failures still persist with hook name, failure kind, message, and trace ID; +- plugin detail loads runtime failures and audit logs; +- declarative-rules behavior remains aligned with replay fixtures where practical; +- provider adapter acceptance scenarios remain host-owned and pass. + +### Frontend Tests + +Plugin page tests should cover: + +- runtime failure section renders when failures exist; +- audit events render with hook/event/risk/trace metadata; +- empty state renders when no runtime events exist; +- refresh invalidates plugin detail or audit queries; +- error states stay readable. + +### Release Gates + +Expected release verification: + +```bash +pnpm --filter create-aio-plugin test +pnpm --filter @aio-coding-hub/plugin-sdk test +pnpm --filter @aio-coding-hub/plugin-sdk typecheck +pnpm check:plugin-api-contract +pnpm check:plugin-system-docs +pnpm typecheck +cd src-tauri && cargo test plugin --lib +cd src-tauri && cargo test gateway_plugin --lib +cd src-tauri && cargo test provider --lib +git diff --check +``` + +The final branch should also pass the repository pre-push hook. + +## Acceptance Criteria + +0.62.1 is accepted when: + +1. A declarative-rules plugin can be scaffolded, diagnosed, strictly validated, replayed with explanation, packed, installed, enabled, and inspected in the GUI. +2. `doctor` and `validate --strict` produce stable structured diagnostics for manifest, rule-file, hook, permission, and runtime-policy problems. +3. `replay --explain` reports evaluated rules, matched rules, action kind, and mutation summary without dumping full sensitive payloads by default. +4. The plugin detail panel shows runtime failures and audit events in a way that identifies hook name, failure kind, event type, risk, and trace ID. +5. Contract drift gates prove Rust, SDK, docs, and scaffold metadata remain aligned for Plugin API v1. +6. Provider adapter work remains internal and covered by acceptance tests. +7. Plugin API v1 remains externally compatible. +8. Provider Plugin API remains closed. +9. WASM remains policy-gated. +10. All release gates pass. + +## Implementation Notes + +The implementation plan should keep commits small: + +1. Developer tool diagnostic model and `doctor`. +2. Strict validation package/rule checks. +3. Replay explanation output. +4. GUI observability improvements. +5. Contract drift generation/checker work. +6. Provider internal acceptance tests. +7. Documentation and release verification. + +Each task should be independently testable. If any task becomes large enough to require unrelated refactors, split it into a follow-up plan rather than widening 0.62.1. From a6785ace30f7afffc1e069b505c7faf7cc3e6e4b Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 23:17:34 +0800 Subject: [PATCH 028/244] docs(plugins): plan 0.62.1 developer loop --- ...coding-hub-0-62-1-plugin-developer-loop.md | 1800 +++++++++++++++++ 1 file changed, 1800 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-1-plugin-developer-loop.md diff --git a/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-1-plugin-developer-loop.md b/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-1-plugin-developer-loop.md new file mode 100644 index 00000000..d876bb6e --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-1-plugin-developer-loop.md @@ -0,0 +1,1800 @@ +# aio-coding-hub 0.62.1 Plugin Developer Loop Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the 0.62.1 plugin developer loop and observability release without changing Plugin API v1 or exposing Provider Plugin API. + +**Architecture:** Keep Plugin API v1 stable and add internal support around it: `create-aio-plugin` owns local diagnostics, strict package validation, and declarative-rules replay explanation; the Tauri GUI presents persisted runtime evidence already produced by the gateway; contract checks prevent SDK, docs, scaffold, and devtools drift. Provider work is limited to host-owned acceptance tests. + +**Tech Stack:** TypeScript, Vitest, React 19, TanStack Query, Tauri 2 generated IPC bindings, Rust, Cargo tests, Node.js contract scripts, Prettier. + +--- + +## Scope Boundaries + +- Do not change the public Plugin API v1 manifest shape. +- Do not add Plugin API v2. +- Do not expose Provider Plugin API. +- Do not enable JS/WebView plugin runtime. +- Do not enable arbitrary marketplace WASM execution by default. +- Do not let plugins control provider selection, failover, OAuth, token counting, or session binding. +- Preserve existing `create-aio-plugin validate ` and `create-aio-plugin replay ` behavior. + +## File Structure + +- `packages/create-aio-plugin/src/devtools.ts`: CLI command routing, package reads, manifest validation, doctor diagnostics, strict validation, replay, replay explanation, pack/sign/verify. +- `packages/create-aio-plugin/src/scaffold.test.ts`: existing `create-aio-plugin` unit tests; add developer-loop tests here to keep the package small. +- `scripts/check-plugin-api-contract.mjs`: contract drift checker; extend it so devtools metadata stays aligned with `docs/plugins/plugin-api-v1-contract.json`. +- `scripts/check-plugin-api-contract.selftest.mjs`: synthetic contract-check fixtures proving new drift checks fail for mismatches and pass for aligned metadata. +- `src/pages/PluginsPage.tsx`: plugin detail panel; add runtime failure and audit evidence UI. +- `src/pages/__tests__/PluginsPage.test.tsx`: frontend render and action tests for plugin observability. +- `src-tauri/src/gateway/proxy/handler/provider_order.rs`: provider ordering acceptance tests. +- `src-tauri/src/gateway/proxy/handler/provider_selection/tests.rs`: session-bound provider acceptance tests. +- `src-tauri/src/gateway/proxy/protocol_bridge/e2e_tests.rs`: cx2cc request/response acceptance tests. +- `src-tauri/src/domain/provider_oauth_limits.rs`: OAuth snapshot acceptance tests. +- `docs/plugins/developer-guide.md`: developer workflow docs for doctor, strict validate, replay explain, pack, install, inspect. +- `docs/plugins/reference/sdk.md`: SDK/devtools reference updates. +- `docs/plugins/reference/declarative-rules.md`: replay explain and strict rule diagnostics reference. +- `docs/plugins/reference/compatibility.md`: compatibility boundary updates. + +## Task 1: Add Developer Diagnostic Model And `doctor` + +**Files:** + +- Modify: `packages/create-aio-plugin/src/devtools.ts` +- Test: `packages/create-aio-plugin/src/scaffold.test.ts` + +- [ ] **Step 1: Write failing tests for structured diagnostics** + +Add `doctorPluginDirectory` and `doctorPluginFiles` to the existing devtools import list in `packages/create-aio-plugin/src/scaffold.test.ts`: + +```ts +import { + doctorPluginDirectory, + doctorPluginFiles, + generateSigningKeyPair, + packPlugin, + packPluginBytes, + packPluginDirectory, + replayHook, + runCreateAioPluginCli, + signPackage, + validatePluginDirectory, + validatePluginFiles, + verifyPackage, +} from "./devtools"; +``` + +Add these tests inside the existing `describe("create-aio-plugin scaffold", () => { ... })` block: + +```ts +it("doctor reports a structured error when plugin.json is missing", () => { + const result = doctorPluginFiles({}); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ + severity: "error", + code: "PLUGIN_MISSING_MANIFEST", + path: "plugin.json", + }), + ]); +}); + +it("doctor reports missing rule files and policy-gated wasm runtime", () => { + const ruleFiles = createPluginScaffold({ + id: "acme.redactor", + name: "Redactor", + template: "rule", + }); + delete ruleFiles["rules/main.json"]; + + const ruleResult = doctorPluginFiles(ruleFiles); + + expect(ruleResult.ok).toBe(false); + expect(ruleResult.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_RULE_FILE_MISSING", + path: "rules/main.json", + }) + ); + + const wasmResult = doctorPluginFiles( + createPluginScaffold({ id: "acme.policy", name: "Policy", template: "wasm" }) + ); + + expect(wasmResult.ok).toBe(false); + expect(wasmResult.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_WASM_ENTRY_MISSING", + path: "plugin.wasm", + }) + ); + expect(wasmResult.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "warn", + code: "PLUGIN_WASM_POLICY_GATED", + }) + ); +}); + +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( + 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 brokenRoot = mkdtempSync(join(tmpdir(), "aio-plugin-doctor-broken-")); + writeFileSync(join(brokenRoot, "README.md"), "# broken\n"); + const brokenOutput: string[] = []; + + expect( + 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" })], + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +pnpm --filter create-aio-plugin test -- src/scaffold.test.ts +``` + +Expected: FAIL because `doctorPluginFiles` and `doctorPluginDirectory` are not exported. + +- [ ] **Step 3: Add the diagnostic types and `doctor` implementation** + +In `packages/create-aio-plugin/src/devtools.ts`, update usage text: + +```ts +const USAGE = [ + "Usage:", + " create-aio-plugin [rule|wasm]", + " create-aio-plugin doctor ", + " create-aio-plugin validate [--strict] ", + " create-aio-plugin replay [--explain] ", + " create-aio-plugin pack ", +].join("\n"); +``` + +Add these exported types after `CliIo`: + +```ts +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"]; + }; +}; + +type DoctorOptions = { + strict?: boolean; +}; +``` + +Add the `doctor` command before the `validate` command branch: + +```ts +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; + } +} +``` + +Add these exported helpers near `validatePluginDirectory`: + +```ts +export function doctorPluginDirectory(root: string, options: DoctorOptions = {}): DoctorResult { + return doctorPluginFiles(readPluginDirectory(root), options); +} + +export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = {}): DoctorResult { + const diagnostics: PluginDiagnostic[] = []; + const manifestText = files["plugin.json"]; + + if (!manifestText) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_MISSING_MANIFEST", + message: "missing plugin.json", + path: "plugin.json", + hint: "Run create-aio-plugin rule or add a Plugin API v1 manifest.", + }); + return { ok: false, diagnostics }; + } + + let manifest: PluginManifest; + try { + manifest = JSON.parse(manifestText) as PluginManifest; + } 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, replay, or pack.", + }); + return { ok: false, diagnostics }; + } + + const validation = validateManifest(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.", + }); + } + + if (manifest.runtime.kind === "declarativeRules") { + for (const rulePath of manifest.runtime.rules) { + if (!files[rulePath]) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_FILE_MISSING", + message: `declarative rule file is missing: ${rulePath}`, + path: rulePath, + hint: "Add the rule file or remove it from runtime.rules.", + }); + } + } + } + + if (manifest.runtime.kind === "wasm") { + const entry = manifest.entry ?? "plugin.wasm"; + if (!files[entry]) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_WASM_ENTRY_MISSING", + message: `wasm entry file is missing: ${entry}`, + path: entry, + hint: "Build the WASM artifact before packing the plugin.", + }); + } + diagnostics.push({ + severity: "warn", + code: "PLUGIN_WASM_POLICY_GATED", + message: "WASM runtime remains policy-gated by the host.", + hint: "Do not rely on marketplace WASM execution unless host policy enables it.", + }); + } + + if (options.strict) { + diagnostics.push(...strictRuleDiagnostics(files, manifest)); + } + + return { + ok: !hasErrorDiagnostics(diagnostics), + diagnostics, + manifest: { + id: manifest.id, + name: manifest.name, + version: manifest.version, + runtime: manifest.runtime.kind, + }, + }; +} + +function hasErrorDiagnostics(diagnostics: readonly PluginDiagnostic[]): boolean { + return diagnostics.some((diagnostic) => diagnostic.severity === "error"); +} + +function strictRuleDiagnostics(_files: ScaffoldFiles, _manifest: PluginManifest): PluginDiagnostic[] { + return []; +} +``` + +- [ ] **Step 4: Run tests to verify the task passes** + +Run: + +```bash +pnpm --filter create-aio-plugin test -- src/scaffold.test.ts +pnpm --filter create-aio-plugin typecheck +``` + +Expected: PASS for both commands. + +- [ ] **Step 5: Commit** + +```bash +git add packages/create-aio-plugin/src/devtools.ts packages/create-aio-plugin/src/scaffold.test.ts +git commit -m "feat(plugin-devtools): add plugin doctor diagnostics" +``` + +## Task 2: Add `validate --strict` Package And Rule Checks + +**Files:** + +- Modify: `packages/create-aio-plugin/src/devtools.ts` +- Test: `packages/create-aio-plugin/src/scaffold.test.ts` + +- [ ] **Step 1: Write failing tests for strict validation** + +Add `validatePluginFilesStrict` to the devtools import list. + +Add these tests inside the existing `describe("create-aio-plugin scaffold", () => { ... })` block: + +```ts +it("validate strict rejects malformed declarative rule documents", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + files["rules/main.json"] = "{ bad json"; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_RULE_FILE_INVALID_JSON", + path: "rules/main.json", + }) + ); +}); + +it("validate strict rejects rules whose hook is not declared by the manifest", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as { + hooks: Array<{ name: string; priority?: number }>; + }; + manifest.hooks = [{ name: "gateway.response.after", priority: 100 }]; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_RULE_HOOK_NOT_DECLARED", + path: "rules/main.json#/rules/0/hook", + }) + ); +}); + +it("validate strict rejects missing permissions for mutating declarative rules", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as { + permissions: string[]; + }; + manifest.permissions = ["request.body.read"]; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_RULE_PERMISSION_MISMATCH", + message: expect.stringContaining("request.body.write"), + }) + ); +}); + +it("legacy validate remains manifest-only while validate strict reports package errors", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + delete files["rules/main.json"]; + + expect(validatePluginFiles(files)).toEqual({ ok: true }); + expect(validatePluginFilesStrict(files)).toMatchObject({ + ok: false, + diagnostics: [expect.objectContaining({ code: "PLUGIN_RULE_FILE_MISSING" })], + }); +}); + +it("validate strict command preserves the old validate command shape unless strict is requested", () => { + const root = mkdtempSync(join(tmpdir(), "aio-plugin-strict-")); + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + delete files["rules/main.json"]; + writeScaffold(root, files); + const normalOutput: string[] = []; + const strictOutput: string[] = []; + + expect( + runCreateAioPluginCli(["validate", root], process.cwd(), { + log: (line) => normalOutput.push(line), + error: (line) => normalOutput.push(line), + }) + ).toBe(0); + expect(JSON.parse(normalOutput[0] ?? "{}")).toEqual({ ok: true }); + + 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_RULE_FILE_MISSING" })], + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +pnpm --filter create-aio-plugin test -- src/scaffold.test.ts +``` + +Expected: FAIL because `validatePluginFilesStrict` and strict CLI routing are missing. + +- [ ] **Step 3: Add strict validation result and CLI routing** + +In `packages/create-aio-plugin/src/devtools.ts`, add this type after `DoctorResult`: + +```ts +export type StrictValidationResult = + | { ok: true; diagnostics: PluginDiagnostic[] } + | { ok: false; error: { code: string; message: string }; diagnostics: PluginDiagnostic[] }; +``` + +Replace the current `validate` command branch with: + +```ts +if (commandOrId === "validate") { + const strict = firstArg === "--strict"; + const pluginDir = strict ? secondArg : firstArg; + try { + 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; + } + io.log(JSON.stringify(validatePluginDirectory(root))); + return 0; + } catch (error) { + io.error(`failed to validate plugin directory: ${errorMessage(error)}`); + return 1; + } +} +``` + +Add these exported helpers near `validatePluginDirectory`: + +```ts +export function validatePluginDirectoryStrict(root: string): StrictValidationResult { + return validatePluginFilesStrict(readPluginDirectory(root)); +} + +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, + }; +} +``` + +- [ ] **Step 4: Add strict declarative-rules diagnostics** + +Replace the temporary `strictRuleDiagnostics` helper in `packages/create-aio-plugin/src/devtools.ts` with: + +```ts +const RULE_TARGET_FIELDS_BY_HOOK: Record = { + "gateway.request.afterBodyRead": ["request.body"], + "gateway.request.beforeSend": ["request.body"], + "gateway.response.after": ["response.body"], + "gateway.response.chunk": ["stream.chunk"], + "gateway.error": ["request.body", "response.body"], + "log.beforePersist": ["log.message"], +}; + +function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): PluginDiagnostic[] { + if (manifest.runtime.kind !== "declarativeRules") return []; + + const diagnostics: PluginDiagnostic[] = []; + const declaredHooks = new Set(manifest.hooks.map((hook) => hook.name)); + const grantedPermissions = new Set(manifest.permissions); + + for (const rulePath of manifest.runtime.rules) { + const text = files[rulePath]; + if (!text) continue; + + let document: { rules?: unknown[] }; + try { + document = JSON.parse(text) as { rules?: unknown[] }; + } catch (error) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_FILE_INVALID_JSON", + message: `rule file is not valid JSON: ${errorMessage(error)}`, + path: rulePath, + hint: "Fix the rule JSON before replaying or packing the plugin.", + }); + continue; + } + + if (!Array.isArray(document.rules)) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULES_MISSING_ARRAY", + message: "rule document must contain a rules array", + path: `${rulePath}#/rules`, + hint: "Use { \"rules\": [...] } as the rule document shape.", + }); + continue; + } + + document.rules.forEach((rawRule, index) => { + const rule = asRecord(rawRule); + if (!rule) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_INVALID", + message: "rule entry must be an object", + path: `${rulePath}#/rules/${index}`, + hint: "Replace the entry with an object containing hook, target, match, and action.", + }); + return; + } + + const hook = typeof rule.hook === "string" ? rule.hook : ""; + if (!hook) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_HOOK_MISSING", + message: "rule hook is required", + path: `${rulePath}#/rules/${index}/hook`, + hint: "Set hook to one of the hooks declared in plugin.json.", + }); + } else if (!declaredHooks.has(hook)) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_HOOK_NOT_DECLARED", + message: `rule hook is not declared in plugin.json: ${hook}`, + path: `${rulePath}#/rules/${index}/hook`, + hint: "Add the hook to manifest.hooks or change the rule hook.", + }); + } + + const target = asRecord(rule.target); + const action = asRecord(rule.action); + const targetField = typeof target?.field === "string" ? target.field : "request.body"; + const actionKind = typeof action?.kind === "string" ? action.kind : ""; + const allowedFields = RULE_TARGET_FIELDS_BY_HOOK[hook] ?? []; + if (hook && allowedFields.length > 0 && !allowedFields.includes(targetField)) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_TARGET_INCOMPATIBLE_WITH_HOOK", + message: `target field ${targetField} is not compatible with hook ${hook}`, + path: `${rulePath}#/rules/${index}/target/field`, + hint: `Use one of: ${allowedFields.join(", ")}.`, + }); + } + + for (const permission of permissionsForRuleTarget(targetField, actionKind)) { + if (!grantedPermissions.has(permission)) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_PERMISSION_MISMATCH", + message: `rule targeting ${targetField} with action ${actionKind || "unknown"} requires ${permission}`, + path: `${rulePath}#/rules/${index}`, + hint: `Add ${permission} to manifest.permissions or change the rule target/action.`, + }); + } + } + }); + } + + return diagnostics; +} + +function permissionsForRuleTarget(field: string, actionKind: string): string[] { + const mutates = actionKind === "replace" || actionKind === "appendMessage"; + switch (field) { + case "response.body": + return mutates ? ["response.body.read", "response.body.write"] : ["response.body.read"]; + case "stream.chunk": + return mutates ? ["stream.inspect", "stream.modify"] : ["stream.inspect"]; + case "log.message": + return ["log.redact"]; + case "request.body": + default: + return mutates ? ["request.body.read", "request.body.write"] : ["request.body.read"]; + } +} +``` + +- [ ] **Step 5: Run tests to verify the task passes** + +Run: + +```bash +pnpm --filter create-aio-plugin test -- src/scaffold.test.ts +pnpm --filter create-aio-plugin typecheck +``` + +Expected: PASS for both commands. + +- [ ] **Step 6: Commit** + +```bash +git add packages/create-aio-plugin/src/devtools.ts packages/create-aio-plugin/src/scaffold.test.ts +git commit -m "feat(plugin-devtools): add strict plugin validation" +``` + +## Task 3: Add `replay --explain` + +**Files:** + +- Modify: `packages/create-aio-plugin/src/devtools.ts` +- Test: `packages/create-aio-plugin/src/scaffold.test.ts` + +- [ ] **Step 1: Write failing tests for replay explanation** + +Add `replayHookExplain` to the devtools import list. + +Add these tests inside the existing `describe("create-aio-plugin scaffold", () => { ... })` block: + +```ts +it("replay explain reports evaluated rules when no rule matches", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + + const result = replayHookExplain(files, "gateway.request.afterBodyRead", { + request: { body: JSON.stringify({ messages: [{ role: "user", content: "hello" }] }) }, + }); + + expect(result).toMatchObject({ + pluginId: "acme.real", + runtime: "declarativeRules", + hook: "gateway.request.afterBodyRead", + evaluatedRuleCount: 1, + matchedRuleIds: [], + actionKind: null, + outputKind: "pass", + result: { action: "pass" }, + }); +}); + +it("replay explain reports matched rule ids and replacement summary", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + + const result = replayHookExplain(files, "gateway.request.afterBodyRead", { + request: { + body: JSON.stringify({ messages: [{ role: "user", content: "SECRET_TOKEN" }] }), + }, + }); + + expect(result).toMatchObject({ + evaluatedRuleCount: 1, + matchedRuleIds: ["redact-token-rule"], + actionKind: "replace", + outputKind: "replace", + mutationSummary: { + changed: true, + field: "requestBody", + targetField: "request.body", + jsonPath: "$.messages[*].content", + }, + }); + expect(JSON.stringify(result)).toContain("[REDACTED]"); +}); + +it("replay explain reports block and warn actions", () => { + const blockResult = replayHookExplain( + rulePluginFilesWithAction({ kind: "block", reason: "blocked" }), + "gateway.request.afterBodyRead", + { request: { body: "danger" } } + ); + expect(blockResult).toMatchObject({ + matchedRuleIds: ["redact-token-rule"], + actionKind: "block", + outputKind: "block", + mutationSummary: { changed: false }, + }); + + const warnResult = replayHookExplain( + rulePluginFilesWithAction({ kind: "warn", message: "careful" }), + "gateway.request.afterBodyRead", + { request: { body: "danger" } } + ); + expect(warnResult).toMatchObject({ + matchedRuleIds: ["redact-token-rule"], + actionKind: "warn", + outputKind: "warn", + mutationSummary: { changed: false }, + }); +}); + +it("replay explain command emits JSON explanation", () => { + const root = mkdtempSync(join(tmpdir(), "aio-plugin-explain-")); + writeScaffold(root, createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" })); + const fixturePath = join(root, "fixture.json"); + writeFileSync( + fixturePath, + JSON.stringify({ + request: { body: JSON.stringify({ messages: [{ role: "user", content: "SECRET_TOKEN" }] }) }, + }) + ); + const output: string[] = []; + + expect( + runCreateAioPluginCli( + ["replay", "--explain", root, fixturePath, "gateway.request.afterBodyRead"], + process.cwd(), + { + log: (line) => output.push(line), + error: (line) => output.push(line), + } + ) + ).toBe(0); + + expect(JSON.parse(output[0] ?? "{}")).toMatchObject({ + pluginId: "acme.real", + outputKind: "replace", + matchedRuleIds: ["redact-token-rule"], + }); +}); +``` + +Update `rulePluginFilesWithRule` so test rule ids are stable and not coupled to scaffold text: + +```ts +if (rule) { + rule.id = "redact-token-rule"; + rule.hook = options.hook; + rule.target = options.target; +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +pnpm --filter create-aio-plugin test -- src/scaffold.test.ts +``` + +Expected: FAIL because `replayHookExplain` and `replay --explain` routing are missing. + +- [ ] **Step 3: Add explanation types and CLI routing** + +In `packages/create-aio-plugin/src/devtools.ts`, add these types after `ReplayRuleResult`: + +```ts +export type ReplayMutationSummary = { + changed: boolean; + field?: "requestBody" | "responseBody" | "streamChunk" | "logMessage"; + targetField?: string; + jsonPath?: string; + beforeLength?: number; + afterLength?: number; +}; + +export type ReplayExplainResult = { + pluginId: string; + runtime: PluginManifest["runtime"]["kind"]; + hook: GatewayHookName; + evaluatedRuleCount: number; + matchedRuleIds: string[]; + actionKind: string | null; + outputKind: ReplayRuleResult["action"]; + mutationSummary: ReplayMutationSummary; + warnings: PluginDiagnostic[]; + result: ReplayRuleResult; +}; + +type ReplayRuleTrace = { + result: ReplayRuleResult; + matched: boolean; + ruleId: string; + actionKind: string | null; + targetField?: string; + jsonPath?: string; +}; +``` + +Replace the current `replay` command branch with: + +```ts +if (commandOrId === "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 [--explain] "); + return 1; + } + try { + const files = readPluginDirectory(resolve(cwd, pluginDir)); + const fixture = JSON.parse(readFileSync(resolve(cwd, fixturePath), "utf8")) as unknown; + const hook = hookName as GatewayHookName; + io.log(JSON.stringify(explain ? replayHookExplain(files, hook, fixture) : replayHook(files, hook, fixture))); + return 0; + } catch (error) { + io.error(`failed to replay plugin hook: ${errorMessage(error)}`); + return 1; + } +} +``` + +Add the exported explanation helper below `replayHook`: + +```ts +export function replayHookExplain( + files: ScaffoldFiles, + hook: GatewayHookName, + context: unknown +): ReplayExplainResult { + const validation = validatePluginFilesStrict(files); + if (!validation.ok) { + throw new Error(`${validation.error.code}: ${validation.error.message}`); + } + + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as PluginManifest; + const warnings: PluginDiagnostic[] = []; + if (manifest.runtime.kind !== "declarativeRules") { + warnings.push({ + severity: "warn", + code: "PLUGIN_REPLAY_UNSUPPORTED_RUNTIME", + message: `replay explain only supports declarativeRules runtime, got ${manifest.runtime.kind}`, + hint: "Use host runtime logs for non-declarative runtimes.", + }); + return { + pluginId: manifest.id, + runtime: manifest.runtime.kind, + hook, + evaluatedRuleCount: 0, + matchedRuleIds: [], + actionKind: null, + outputKind: "pass", + mutationSummary: { changed: false }, + warnings, + result: { action: "pass" }, + }; + } + + let evaluatedRuleCount = 0; + const matchedRuleIds: string[] = []; + let finalTrace: ReplayRuleTrace | null = null; + + for (const rulePath of manifest.runtime.rules) { + const document = JSON.parse(files[rulePath] ?? "{\"rules\":[]}") as { rules?: unknown[] }; + for (const [index, rule] of (document.rules ?? []).entries()) { + evaluatedRuleCount += 1; + const trace = replayDeclarativeRuleWithTrace(rule, hook, context, `${rulePath}#${index + 1}`); + if (trace.matched) matchedRuleIds.push(trace.ruleId); + if (trace.result.action !== "pass") { + finalTrace = trace; + break; + } + } + if (finalTrace) break; + } + + const result = finalTrace?.result ?? { action: "pass" }; + return { + pluginId: manifest.id, + runtime: manifest.runtime.kind, + hook, + evaluatedRuleCount, + matchedRuleIds, + actionKind: finalTrace?.actionKind ?? null, + outputKind: result.action, + mutationSummary: mutationSummaryFromReplayResult(result, finalTrace), + warnings, + result, + }; +} +``` + +- [ ] **Step 4: Instrument declarative rule replay without changing `replayHook` output** + +Replace the body of `replayDeclarativeRule` with: + +```ts +return replayDeclarativeRuleWithTrace(rawRule, hook, context, "rule").result; +``` + +Add this helper before `replayDeclarativeRule`: + +```ts +function replayDeclarativeRuleWithTrace( + rawRule: unknown, + hook: GatewayHookName, + context: unknown, + fallbackRuleId: string +): ReplayRuleTrace { + const rule = asRecord(rawRule); + const ruleId = typeof rule?.id === "string" ? rule.id : fallbackRuleId; + if (rule?.hook !== hook) { + return { result: { action: "pass" }, matched: false, ruleId, actionKind: null }; + } + const target = asRecord(rule.target); + const matcher = asRecord(rule.matcher) ?? asRecord(rule.match); + const action = asRecord(rule.action); + const actionKind = typeof action?.kind === "string" ? action.kind : null; + const targetField = typeof target?.field === "string" ? target.field : "request.body"; + const jsonPath = + typeof target?.jsonPath === "string" + ? target.jsonPath + : target?.kind === "jsonPath" && typeof target.path === "string" + ? target.path + : undefined; + + if (!target || !matcher || !action) { + return { result: { action: "pass" }, matched: false, ruleId, actionKind, targetField, jsonPath }; + } + const regex = compileReplayRegex(matcher); + if (!regex) { + return { result: { action: "pass" }, matched: false, ruleId, actionKind, targetField, jsonPath }; + } + + const text = textFromFixture(context, targetField); + if (!text) { + return { result: { action: "pass" }, matched: false, ruleId, actionKind, targetField, jsonPath }; + } + + const result = + !jsonPath || (target.field && target.field !== "request.body" && target.field !== "response.body") + ? replayTextAction(text, regex, action, targetField) + : replayJsonPathAction(text, jsonPath, regex, action, targetField); + + return { + result, + matched: result.action !== "pass" || replayRegexMatches(text, regex, jsonPath), + ruleId, + actionKind, + targetField, + jsonPath, + }; +} + +function replayRegexMatches(text: string, regex: RegExp, jsonPath: string | undefined): boolean { + regex.lastIndex = 0; + if (!jsonPath) return regex.test(text); + try { + const root = JSON.parse(text) as unknown; + let matched = false; + const segments = parseReplayJsonPath(jsonPath); + if (!segments) return false; + applyToJsonStrings(root, segments, (candidate) => { + regex.lastIndex = 0; + if (regex.test(candidate.value)) matched = true; + }); + return matched; + } catch { + return false; + } +} + +function mutationSummaryFromReplayResult( + result: ReplayRuleResult, + trace: ReplayRuleTrace | null +): ReplayMutationSummary { + if (result.action !== "replace") return { changed: false }; + if ("requestBody" in result) { + return summaryForReplacement("requestBody", result.requestBody, trace); + } + if ("responseBody" in result) { + return summaryForReplacement("responseBody", result.responseBody, trace); + } + if ("streamChunk" in result) { + return summaryForReplacement("streamChunk", result.streamChunk, trace); + } + if ("logMessage" in result) { + return summaryForReplacement("logMessage", result.logMessage, trace); + } + return { changed: false }; +} + +function summaryForReplacement( + field: ReplayMutationSummary["field"], + value: string, + trace: ReplayRuleTrace | null +): ReplayMutationSummary { + return { + changed: true, + field, + targetField: trace?.targetField, + jsonPath: trace?.jsonPath, + afterLength: value.length, + }; +} +``` + +- [ ] **Step 5: Run tests to verify the task passes** + +Run: + +```bash +pnpm --filter create-aio-plugin test -- src/scaffold.test.ts +pnpm --filter create-aio-plugin typecheck +``` + +Expected: PASS for both commands, and existing `replayHook` assertions still pass unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add packages/create-aio-plugin/src/devtools.ts packages/create-aio-plugin/src/scaffold.test.ts +git commit -m "feat(plugin-devtools): explain declarative rule replay" +``` + +## Task 4: Improve GUI Plugin Runtime Observability + +**Files:** + +- Modify: `src/pages/PluginsPage.tsx` +- Test: `src/pages/__tests__/PluginsPage.test.tsx` + +- [ ] **Step 1: Write failing frontend tests** + +In `src/pages/__tests__/PluginsPage.test.tsx`, add a mock for the existing clipboard service near the other mocks: + +```ts +vi.mock("../../services/clipboard", () => ({ + copyText: vi.fn().mockResolvedValue(undefined), +})); +``` + +Add these tests inside `describe("pages/PluginsPage", () => { ... })`: + +```tsx +it("renders runtime failures with hook, failure kind, and trace id", () => { + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary()], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginQuery).mockReturnValue({ + data: detail({ + runtime_failures: [ + { + id: 10, + plugin_id: "community.prompt-helper", + hook_name: "gateway.request.afterBodyRead", + failure_kind: "timeout", + message: "Hook timed out", + trace_id: "trace-runtime-1", + created_at: 50, + }, + ], + }), + isLoading: false, + isFetching: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText("运行观测")).toBeInTheDocument(); + expect(screen.getByText("Hook timed out")).toBeInTheDocument(); + expect(screen.getByText("timeout")).toBeInTheDocument(); + expect(screen.getAllByText("gateway.request.afterBodyRead").length).toBeGreaterThan(0); + expect(screen.getByText("trace-runtime-1")).toBeInTheDocument(); +}); + +it("renders audit events with event type, risk, and trace id", () => { + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary()], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginQuery).mockReturnValue({ + data: detail({ + audit_logs: [ + { + id: 11, + plugin_id: "community.prompt-helper", + trace_id: "trace-audit-1", + event_type: "plugin.hook.failed", + risk_level: "high", + message: "Rule failed open", + details: { hookName: "gateway.request.afterBodyRead", failureKind: "timeout" }, + created_at: 60, + }, + ], + }), + isLoading: false, + isFetching: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText("Rule failed open")).toBeInTheDocument(); + expect(screen.getByText("plugin.hook.failed")).toBeInTheDocument(); + expect(screen.getByText("high")).toBeInTheDocument(); + expect(screen.getByText("trace-audit-1")).toBeInTheDocument(); + expect(screen.getAllByText("gateway.request.afterBodyRead").length).toBeGreaterThan(0); +}); + +it("shows an empty runtime observability state before a hook has run", () => { + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary()], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginQuery).mockReturnValue({ + data: detail({ audit_logs: [], runtime_failures: [] }), + isLoading: false, + isFetching: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText("还没有记录到插件运行事件")).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +pnpm test:unit -- src/pages/__tests__/PluginsPage.test.tsx +``` + +Expected: FAIL because the plugin detail panel does not render the new runtime observability section. + +- [ ] **Step 3: Add observability helpers and section** + +In `src/pages/PluginsPage.tsx`, add imports: + +```ts +import { Copy, Download, RotateCcw, Upload, Power, PowerOff, RefreshCw, ShieldAlert, Trash2 } from "lucide-react"; +import { copyText } from "../services/clipboard"; +``` + +Add these helpers before `PluginDetailPanel`: + +```tsx +function detailValue(details: JsonValue, key: string): string | null { + const record = jsonRecord(details); + const value = record?.[key]; + return typeof value === "string" && value.trim() ? value : null; +} + +function TraceIdButton({ traceId }: { traceId: string | null }) { + if (!traceId) return -; + return ( + + ); +} + +function RuntimeObservabilitySection({ detail }: { detail: PluginDetail }) { + const hasFailures = detail.runtime_failures.length > 0; + const hasAuditLogs = detail.audit_logs.length > 0; + + return ( +
+ {!hasFailures && !hasAuditLogs ? ( +
+ 还没有记录到插件运行事件。启用插件并让请求经过 gateway 后,这里会显示 hook、失败类型、审计事件和 trace ID。 +
+ ) : null} + + {hasFailures ? ( +
+
运行失败
+ {detail.runtime_failures.slice(0, 5).map((failure) => ( +
+
+ {failure.message} + + {failure.failure_kind} + +
+
+ {failure.hook_name ?? "-"} + +
+
+ ))} +
+ ) : null} + + {hasAuditLogs ? ( +
+
审计事件
+ {detail.audit_logs.slice(0, 8).map((log) => { + const hookName = detailValue(log.details, "hookName"); + const failureKind = detailValue(log.details, "failureKind"); + return ( +
+
+ {log.message} + {log.risk_level} +
+
+ {log.event_type} + {hookName ? {hookName} : null} + {failureKind ? {failureKind} : null} + +
+
+ ); + })} +
+ ) : null} +
+ ); +} +``` + +- [ ] **Step 4: Render the section in `PluginDetailPanel` and remove the old audit-only preview** + +In `PluginDetailPanel`, add this section after `Section title="设置"` and before `Section title="开发者信息"`: + +```tsx + +``` + +Remove the old `detail.audit_logs.slice(0, 5)` block inside `Section title="开发者信息"` so audit events are shown in one place. + +- [ ] **Step 5: Run tests to verify the task passes** + +Run: + +```bash +pnpm test:unit -- src/pages/__tests__/PluginsPage.test.tsx +pnpm typecheck +``` + +Expected: PASS for both commands. + +- [ ] **Step 6: Commit** + +```bash +git add src/pages/PluginsPage.tsx src/pages/__tests__/PluginsPage.test.tsx +git commit -m "feat(plugins): show runtime observability in plugin details" +``` + +## Task 5: Strengthen Plugin API Contract Drift Gates For Devtools + +**Files:** + +- Modify: `scripts/check-plugin-api-contract.mjs` +- Modify: `scripts/check-plugin-api-contract.selftest.mjs` +- Test: `scripts/check-plugin-api-contract.selftest.mjs` + +- [ ] **Step 1: Write failing selftest coverage for devtools drift** + +In `scripts/check-plugin-api-contract.selftest.mjs`, add this helper after `writePassingScaffold`: + +```js +function writePassingDevtools(root) { + writeFileSync( + join(root, "packages/create-aio-plugin/src/devtools.ts"), + [ + "gateway.request.afterBodyRead gateway.request.beforeSend gateway.response.chunk gateway.response.after gateway.error log.beforePersist", + "request.body.read request.body.write response.body.read response.body.write stream.inspect stream.modify log.redact", + "requestBody responseBody streamChunk logMessage headers", + "declarativeRules wasm", + "PLUGIN_RULE_PERMISSION_MISMATCH PLUGIN_REPLAY_UNSUPPORTED_RUNTIME PLUGIN_WASM_POLICY_GATED", + "validatePluginFilesStrict replayHookExplain doctorPluginFiles", + ].join("\n") + ); +} +``` + +Call `writePassingDevtools(root);` at the end of `writePassingScaffold(root)`. + +Add this negative fixture near the other negative fixtures: + +```js +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", + readPermissions: ["request.body.read"], + writePermissions: ["request.body.write"], + permissionDependencies: { "request.body.write": ["request.body.read"] }, + mutationFields: ["requestBody"], + contextFields: ["traceId"], + timeoutMs: 150, + defaultFailurePolicy: "fail-open", + reservedHeaderPolicy: "block-gateway-owned", + }, + }, + communityRuntimes: ["declarativeRules"], + policyGatedRuntimes: ["wasm"], + officialRuntimes: ["native:privacyFilter"], +}); +writePassingScaffold(missingDevtoolsMetadataRoot); +writeFileSync( + join(missingDevtoolsMetadataRoot, "packages/create-aio-plugin/src/devtools.ts"), + "declarativeRules validatePluginFilesStrict replayHookExplain doctorPluginFiles" +); + +const missingDevtoolsMetadataResult = runCheck(missingDevtoolsMetadataRoot); +if ( + missingDevtoolsMetadataResult.status === 0 || + !missingDevtoolsMetadataResult.stderr.includes("packages/create-aio-plugin/src/devtools.ts") || + !missingDevtoolsMetadataResult.stderr.includes("requestBody") +) { + throw new Error( + `expected devtools contract failure, got status ${missingDevtoolsMetadataResult.status}\n${missingDevtoolsMetadataResult.stderr}` + ); +} +``` + +- [ ] **Step 2: Run selftest to verify it fails** + +Run: + +```bash +node scripts/check-plugin-api-contract.selftest.mjs +``` + +Expected: FAIL because `scripts/check-plugin-api-contract.mjs` does not inspect devtools metadata yet. + +- [ ] **Step 3: Add devtools contract checks** + +In `scripts/check-plugin-api-contract.mjs`, after the existing scaffold checks, add: + +```js +const devtools = readText("packages/create-aio-plugin/src/devtools.ts"); +requireIncludes( + "packages/create-aio-plugin/src/devtools.ts", + devtools, + contract.activeHooks, + "developer tool active hook" +); +requireIncludes( + "packages/create-aio-plugin/src/devtools.ts", + devtools, + contract.activePermissions, + "developer tool active permission" +); +requireIncludes( + "packages/create-aio-plugin/src/devtools.ts", + devtools, + runtimeTokens(contract), + "developer tool runtime" +); +requireIncludes( + "packages/create-aio-plugin/src/devtools.ts", + devtools, + contract.activeMutationFields ?? [], + "developer tool mutation field" +); +requireIncludes( + "packages/create-aio-plugin/src/devtools.ts", + devtools, + [ + "doctorPluginFiles", + "validatePluginFilesStrict", + "replayHookExplain", + "PLUGIN_RULE_PERMISSION_MISMATCH", + "PLUGIN_REPLAY_UNSUPPORTED_RUNTIME", + "PLUGIN_WASM_POLICY_GATED", + ], + "developer tool diagnostic surface" +); +requireNotIncludes( + "packages/create-aio-plugin/src/devtools.ts", + devtools, + ["contextPatch"], + "legacy mutation field" +); +``` + +- [ ] **Step 4: Run contract checks to verify the task passes** + +Run: + +```bash +node scripts/check-plugin-api-contract.selftest.mjs +pnpm check:plugin-api-contract +``` + +Expected: PASS for both commands. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/check-plugin-api-contract.mjs scripts/check-plugin-api-contract.selftest.mjs +git commit -m "test(plugins): guard devtools contract drift" +``` + +## Task 6: Add Provider Internal Acceptance Tests Without Public API Changes + +**Files:** + +- Modify: `src-tauri/src/gateway/proxy/handler/provider_order.rs` +- Modify: `src-tauri/src/gateway/proxy/handler/provider_selection/tests.rs` +- Modify: `src-tauri/src/gateway/proxy/protocol_bridge/e2e_tests.rs` +- Modify: `src-tauri/src/domain/provider_oauth_limits.rs` + +- [ ] **Step 1: Add provider ordering acceptance test** + +In `src-tauri/src/gateway/proxy/handler/provider_order.rs`, add this test inside the existing `#[cfg(test)] mod tests`: + +```rust +#[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]); +} +``` + +- [ ] **Step 2: Add session binding acceptance test** + +In `src-tauri/src/gateway/proxy/handler/provider_selection/tests.rs`, add this test at the end of the file: + +```rust +#[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, Some(vec![id1, id2]), 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]); +} +``` + +- [ ] **Step 3: Add cx2cc bridge acceptance test** + +In `src-tauri/src/gateway/proxy/protocol_bridge/e2e_tests.rs`, add this test inside the existing test module: + +```rust +#[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); +} +``` + +- [ ] **Step 4: Add OAuth snapshot acceptance test** + +In `src-tauri/src/domain/provider_oauth_limits.rs`, add this test inside the existing `#[cfg(test)] mod tests`: + +```rust +#[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 + ); +} +``` + +- [ ] **Step 5: Run Rust tests to verify the task passes** + +Run: + +```bash +cd src-tauri && cargo test provider_order --lib +cd src-tauri && cargo test provider_selection --lib +cd src-tauri && cargo test protocol_bridge --lib +cd src-tauri && cargo test provider_oauth_limits --lib +``` + +Expected: PASS for all four commands. + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/src/gateway/proxy/handler/provider_order.rs src-tauri/src/gateway/proxy/handler/provider_selection/tests.rs src-tauri/src/gateway/proxy/protocol_bridge/e2e_tests.rs src-tauri/src/domain/provider_oauth_limits.rs +git commit -m "test(provider): add internal acceptance coverage" +``` + +## Task 7: Update Docs And Run Release Gates + +**Files:** + +- Modify: `docs/plugins/developer-guide.md` +- Modify: `docs/plugins/reference/sdk.md` +- Modify: `docs/plugins/reference/declarative-rules.md` +- Modify: `docs/plugins/reference/compatibility.md` + +- [ ] **Step 1: Update developer guide workflow** + +In `docs/plugins/developer-guide.md`, update the command sequence so it reads: + +```md +pnpm --filter create-aio-plugin test +pnpm create-aio-plugin acme.redactor rule +pnpm create-aio-plugin doctor ./acme.redactor +pnpm create-aio-plugin validate --strict ./acme.redactor +pnpm create-aio-plugin replay --explain ./acme.redactor ./fixtures/claude-request.json gateway.request.afterBodyRead +pnpm create-aio-plugin replay --explain ./acme.redactor ./fixtures/codex-request.json gateway.request.afterBodyRead +pnpm create-aio-plugin pack ./acme.redactor +``` + +Add this short explanation near the first `create-aio-plugin validate` section: + +```md +`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 rule files, rule hooks, target compatibility, and rule permission mismatches. +Warnings do not fail the command in 0.62.1; any `error` severity diagnostic returns a non-zero exit code. +``` + +- [ ] **Step 2: Update SDK reference** + +In `docs/plugins/reference/sdk.md`, update the devtools command block to: + +```md +pnpm create-aio-plugin doctor ./acme.redactor +pnpm create-aio-plugin validate --strict ./acme.redactor +pnpm create-aio-plugin replay --explain ./acme.redactor ./fixtures/request.json gateway.request.afterBodyRead +pnpm create-aio-plugin pack ./acme.redactor +``` + +Add the stable diagnostic shape: + +```json +{ + "severity": "error", + "code": "PLUGIN_RULE_PERMISSION_MISMATCH", + "message": "rule targeting request.body with action replace requires request.body.write", + "path": "rules/main.json#/rules/0", + "hint": "Add request.body.write to manifest.permissions or change the rule target/action." +} +``` + +- [ ] **Step 3: Update declarative rules reference** + +In `docs/plugins/reference/declarative-rules.md`, add this explanation model: + +```json +{ + "pluginId": "acme.redactor", + "runtime": "declarativeRules", + "hook": "gateway.request.afterBodyRead", + "evaluatedRuleCount": 1, + "matchedRuleIds": ["redact-token-rule"], + "actionKind": "replace", + "outputKind": "replace", + "mutationSummary": { + "changed": true, + "field": "requestBody", + "targetField": "request.body", + "jsonPath": "$.messages[*].content" + }, + "warnings": [], + "result": { + "action": "replace", + "requestBody": "{\"messages\":[{\"role\":\"user\",\"content\":\"[REDACTED]\"}]}" + } +} +``` + +Add this sentence below the JSON: + +```md +`replay --explain` is a deterministic local simulator for the supported declarative-rules subset. The Rust gateway remains the source of truth for runtime execution, audit events, failure policy, timeouts, and circuit behavior. +``` + +- [ ] **Step 4: Update compatibility reference** + +In `docs/plugins/reference/compatibility.md`, add this 0.62.1 boundary note: + +```md +0.62.1 does not change Plugin API v1. `doctor`, `validate --strict`, and `replay --explain` are developer tooling around the same manifest and hook contract. + +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. + +WASM remains policy-gated. The scaffold and pack flow can carry WASM artifacts, but marketplace WASM execution is not enabled by default. +``` + +- [ ] **Step 5: Run documentation and release verification** + +Run: + +```bash +pnpm exec prettier --check docs/plugins/developer-guide.md docs/plugins/reference/sdk.md docs/plugins/reference/declarative-rules.md docs/plugins/reference/compatibility.md +pnpm --filter create-aio-plugin test +pnpm --filter create-aio-plugin typecheck +pnpm --filter @aio-coding-hub/plugin-sdk test +pnpm --filter @aio-coding-hub/plugin-sdk typecheck +pnpm check:plugin-api-contract +pnpm check:plugin-system-docs +pnpm test:unit -- src/pages/__tests__/PluginsPage.test.tsx +pnpm typecheck +cd src-tauri && cargo test plugin --lib +cd src-tauri && cargo test gateway_plugin --lib +cd src-tauri && cargo test provider --lib +git diff --check +``` + +Expected: PASS for every command. + +- [ ] **Step 6: Commit** + +```bash +git add docs/plugins/developer-guide.md docs/plugins/reference/sdk.md docs/plugins/reference/declarative-rules.md docs/plugins/reference/compatibility.md +git commit -m "docs(plugins): document 0.62.1 developer loop" +``` + +## Final Acceptance Checklist + +- [ ] `create-aio-plugin doctor ./plugin` returns structured diagnostics and non-zero exit code when any error diagnostic exists. +- [ ] `create-aio-plugin validate ./plugin` preserves the existing manifest-only success/failure shape. +- [ ] `create-aio-plugin validate --strict ./plugin` checks package health, rule documents, declared hooks, target compatibility, and rule permissions. +- [ ] `create-aio-plugin replay ./plugin fixture.json hook` preserves existing output. +- [ ] `create-aio-plugin replay --explain ./plugin fixture.json hook` reports evaluated rules, matched rule ids, action kind, output kind, mutation summary, and warnings. +- [ ] Plugin detail GUI shows runtime failures and audit events with hook, failure kind, event type, risk, and trace ID. +- [ ] Contract drift gates include devtools metadata in addition to SDK, Rust, docs, scaffold, and WASM SDK. +- [ ] Provider adapter work remains internal and is covered by acceptance tests. +- [ ] Plugin API v1 remains externally compatible. +- [ ] Provider Plugin API remains closed. +- [ ] WASM remains policy-gated. + +## Self-Review Notes + +- Spec coverage: Tasks 1-3 cover local developer loop; Task 4 covers GUI observability; Task 5 covers contract drift prevention; Task 6 covers internal provider acceptance; Task 7 covers docs and release gates. +- Type consistency: diagnostic types are shared by `doctor`, strict validation, and replay warnings; `ReplayExplainResult` uses the existing `ReplayRuleResult` action union without changing old replay output. +- Compatibility: legacy `validate` and `replay` routes remain available with their existing positional arguments; new behavior is gated by `doctor`, `--strict`, and `--explain`. From 9354298d9b578a90b9b491a5a1cec6bcad6d89be Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 23:23:50 +0800 Subject: [PATCH 029/244] feat(plugin-devtools): add plugin doctor diagnostics --- packages/create-aio-plugin/src/devtools.ts | 147 +++++++++++++++++- .../create-aio-plugin/src/scaffold.test.ts | 89 +++++++++++ 2 files changed, 234 insertions(+), 2 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index a6428370..6a029817 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -29,11 +29,37 @@ 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"]; + }; +}; + +type DoctorOptions = { + strict?: boolean; +}; + const USAGE = [ "Usage:", " create-aio-plugin [rule|wasm]", - " create-aio-plugin validate ", - " create-aio-plugin replay ", + " create-aio-plugin doctor ", + " create-aio-plugin validate [--strict] ", + " create-aio-plugin replay [--explain] ", " create-aio-plugin pack ", ].join("\n"); @@ -45,6 +71,22 @@ export function runCreateAioPluginCli(args: string[], cwd: string, io: CliIo = c return 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") { try { io.log(JSON.stringify(validatePluginDirectory(resolve(cwd, firstArg ?? ".")))); @@ -167,10 +209,111 @@ export function validatePluginDirectory(root: string): ValidationResult { return validatePluginFiles(readPluginDirectory(root)); } +export function doctorPluginDirectory(root: string, options: DoctorOptions = {}): DoctorResult { + return doctorPluginFiles(readPluginDirectory(root), options); +} + +export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = {}): DoctorResult { + const diagnostics: PluginDiagnostic[] = []; + const manifestText = files["plugin.json"]; + + if (!manifestText) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_MISSING_MANIFEST", + message: "missing plugin.json", + path: "plugin.json", + hint: "Run create-aio-plugin rule or add a Plugin API v1 manifest.", + }); + return { ok: false, diagnostics }; + } + + let manifest: PluginManifest; + try { + manifest = JSON.parse(manifestText) as PluginManifest; + } 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, replay, or pack.", + }); + return { ok: false, diagnostics }; + } + + const validation = validateManifest(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.", + }); + } + + if (manifest.runtime.kind === "declarativeRules") { + for (const rulePath of manifest.runtime.rules) { + if (!files[rulePath]) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_FILE_MISSING", + message: `declarative rule file is missing: ${rulePath}`, + path: rulePath, + hint: "Add the rule file or remove it from runtime.rules.", + }); + } + } + } + + if (manifest.runtime.kind === "wasm") { + const entry = manifest.entry ?? "plugin.wasm"; + if (!files[entry]) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_WASM_ENTRY_MISSING", + message: `wasm entry file is missing: ${entry}`, + path: entry, + hint: "Build the WASM artifact before packing the plugin.", + }); + } + diagnostics.push({ + severity: "warn", + code: "PLUGIN_WASM_POLICY_GATED", + message: "WASM runtime remains policy-gated by the host.", + hint: "Do not rely on marketplace WASM execution unless host policy enables it.", + }); + } + + if (options.strict) { + diagnostics.push(...strictRuleDiagnostics(files, manifest)); + } + + return { + ok: !hasErrorDiagnostics(diagnostics), + diagnostics, + manifest: { + id: manifest.id, + name: manifest.name, + version: manifest.version, + runtime: manifest.runtime.kind, + }, + }; +} + export function packPluginDirectory(root: string): PackedPlugin { return packPluginBytes(readPluginDirectoryBytes(root)); } +function hasErrorDiagnostics(diagnostics: readonly PluginDiagnostic[]): boolean { + return diagnostics.some((diagnostic) => diagnostic.severity === "error"); +} + +function strictRuleDiagnostics(_files: ScaffoldFiles, _manifest: PluginManifest): PluginDiagnostic[] { + return []; +} + 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; diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index 310f578c..fe786774 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -6,6 +6,8 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createPluginScaffold } from "./scaffold"; import { + doctorPluginDirectory, + doctorPluginFiles, generateSigningKeyPair, packPlugin, packPluginBytes, @@ -202,6 +204,93 @@ describe("create-aio-plugin scaffold", () => { expect(result).toEqual({ ok: true }); }); + it("doctor reports a structured error when plugin.json is missing", () => { + const result = doctorPluginFiles({}); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ + severity: "error", + code: "PLUGIN_MISSING_MANIFEST", + path: "plugin.json", + }), + ]); + }); + + it("doctor reports missing rule files and policy-gated wasm runtime", () => { + const ruleFiles = createPluginScaffold({ + id: "acme.redactor", + name: "Redactor", + template: "rule", + }); + delete ruleFiles["rules/main.json"]; + + const ruleResult = doctorPluginFiles(ruleFiles); + + expect(ruleResult.ok).toBe(false); + expect(ruleResult.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_RULE_FILE_MISSING", + path: "rules/main.json", + }) + ); + + const wasmResult = doctorPluginFiles( + createPluginScaffold({ id: "acme.policy", name: "Policy", template: "wasm" }) + ); + + expect(wasmResult.ok).toBe(false); + expect(wasmResult.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_WASM_ENTRY_MISSING", + path: "plugin.wasm", + }) + ); + expect(wasmResult.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "warn", + code: "PLUGIN_WASM_POLICY_GATED", + }) + ); + }); + + 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[] = []; + + const directoryResult = doctorPluginDirectory(root); + + expect(directoryResult.ok).toBe(true); + expect( + 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 brokenRoot = mkdtempSync(join(tmpdir(), "aio-plugin-doctor-broken-")); + writeFileSync(join(brokenRoot, "README.md"), "# broken\n"); + const brokenOutput: string[] = []; + + expect( + 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" })], + }); + }); + 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" })); From 52bbb50a4848b5e11325b6e1f02582b4d07cbcff Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 23:34:54 +0800 Subject: [PATCH 030/244] fix(plugin-devtools): return doctor diagnostics for invalid manifests --- packages/create-aio-plugin/src/devtools.ts | 69 ++++++++++++++++--- .../create-aio-plugin/src/scaffold.test.ts | 18 +++-- 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 6a029817..781a4661 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -242,7 +242,18 @@ export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = return { ok: false, diagnostics }; } - const validation = validateManifest(manifest); + let validation: ValidationResult; + try { + validation = validateManifest(manifest); + } catch (error) { + validation = { + ok: false, + error: { + code: "PLUGIN_INVALID_MANIFEST", + message: errorMessage(error), + }, + }; + } if (!validation.ok) { diagnostics.push({ severity: "error", @@ -253,8 +264,11 @@ export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = }); } - if (manifest.runtime.kind === "declarativeRules") { - for (const rulePath of manifest.runtime.rules) { + const runtimeKind = manifestRuntimeKind(manifest); + const runtime = manifest.runtime; + + if (runtimeKind === "declarativeRules" && runtime.kind === "declarativeRules") { + for (const rulePath of runtime.rules) { if (!files[rulePath]) { diagnostics.push({ severity: "error", @@ -267,7 +281,7 @@ export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = } } - if (manifest.runtime.kind === "wasm") { + if (runtimeKind === "wasm") { const entry = manifest.entry ?? "plugin.wasm"; if (!files[entry]) { diagnostics.push({ @@ -290,15 +304,12 @@ export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = diagnostics.push(...strictRuleDiagnostics(files, manifest)); } + const manifestSummary = doctorManifestSummary(manifest, runtimeKind); + return { ok: !hasErrorDiagnostics(diagnostics), diagnostics, - manifest: { - id: manifest.id, - name: manifest.name, - version: manifest.version, - runtime: manifest.runtime.kind, - }, + ...(manifestSummary ? { manifest: manifestSummary } : {}), }; } @@ -310,10 +321,46 @@ function hasErrorDiagnostics(diagnostics: readonly PluginDiagnostic[]): boolean return diagnostics.some((diagnostic) => diagnostic.severity === "error"); } -function strictRuleDiagnostics(_files: ScaffoldFiles, _manifest: PluginManifest): PluginDiagnostic[] { +function strictRuleDiagnostics( + _files: ScaffoldFiles, + _manifest: PluginManifest +): PluginDiagnostic[] { return []; } +function manifestRuntimeKind( + manifest: Partial +): PluginManifest["runtime"]["kind"] | null { + const runtime = asRecord(manifest.runtime); + if (runtime?.kind === "declarativeRules" && Array.isArray(runtime.rules)) { + return "declarativeRules"; + } + if (runtime?.kind === "wasm") { + return "wasm"; + } + return null; +} + +function doctorManifestSummary( + manifest: Partial, + runtimeKind: PluginManifest["runtime"]["kind"] | null +): DoctorResult["manifest"] | undefined { + if ( + typeof manifest.id !== "string" || + typeof manifest.name !== "string" || + typeof manifest.version !== "string" || + !runtimeKind + ) { + return undefined; + } + return { + id: manifest.id, + name: manifest.name, + version: manifest.version, + runtime: runtimeKind, + }; +} + 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; diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index fe786774..84618c14 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -217,6 +217,19 @@ describe("create-aio-plugin scaffold", () => { ]); }); + it("doctor reports an invalid manifest diagnostic for incomplete plugin.json", () => { + const result = doctorPluginFiles({ "plugin.json": "{}\n" }); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ + severity: "error", + code: "PLUGIN_INVALID_ID", + path: "plugin.json", + }), + ]); + }); + it("doctor reports missing rule files and policy-gated wasm runtime", () => { const ruleFiles = createPluginScaffold({ id: "acme.redactor", @@ -258,10 +271,7 @@ describe("create-aio-plugin scaffold", () => { 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" }) - ); + writeScaffold(root, createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" })); const output: string[] = []; const directoryResult = doctorPluginDirectory(root); From ffab898636468b35a12f7801ff6fc785dfb00d9b Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 23:51:36 +0800 Subject: [PATCH 031/244] fix(plugin-devtools): classify malformed doctor manifests --- packages/create-aio-plugin/src/devtools.ts | 15 ++++++++++-- .../create-aio-plugin/src/scaffold.test.ts | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 781a4661..74848d77 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -217,7 +217,7 @@ export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = const diagnostics: PluginDiagnostic[] = []; const manifestText = files["plugin.json"]; - if (!manifestText) { + if (manifestText == null) { diagnostics.push({ severity: "error", code: "PLUGIN_MISSING_MANIFEST", @@ -230,7 +230,18 @@ export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = let manifest: PluginManifest; try { - manifest = JSON.parse(manifestText) as PluginManifest; + 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 PluginManifest; } catch (error) { diagnostics.push({ severity: "error", diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index 84618c14..87f666be 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -230,6 +230,30 @@ describe("create-aio-plugin scaffold", () => { ]); }); + it("doctor distinguishes empty and non-object plugin.json content", () => { + const emptyResult = doctorPluginFiles({ "plugin.json": "" }); + + expect(emptyResult.ok).toBe(false); + expect(emptyResult.diagnostics).toEqual([ + expect.objectContaining({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST_JSON", + path: "plugin.json", + }), + ]); + + const nullResult = doctorPluginFiles({ "plugin.json": "null\n" }); + + expect(nullResult.ok).toBe(false); + expect(nullResult.diagnostics).toEqual([ + expect.objectContaining({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + path: "plugin.json", + }), + ]); + }); + it("doctor reports missing rule files and policy-gated wasm runtime", () => { const ruleFiles = createPluginScaffold({ id: "acme.redactor", From 6478a8643647b421190f7b16a5d17c8d25df71ed Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Mon, 22 Jun 2026 23:57:15 +0800 Subject: [PATCH 032/244] fix(plugin-devtools): check runtime file presence by path --- packages/create-aio-plugin/src/devtools.ts | 4 +-- .../create-aio-plugin/src/scaffold.test.ts | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 74848d77..4fded1b4 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -280,7 +280,7 @@ export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = if (runtimeKind === "declarativeRules" && runtime.kind === "declarativeRules") { for (const rulePath of runtime.rules) { - if (!files[rulePath]) { + if (files[rulePath] == null) { diagnostics.push({ severity: "error", code: "PLUGIN_RULE_FILE_MISSING", @@ -294,7 +294,7 @@ export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = if (runtimeKind === "wasm") { const entry = manifest.entry ?? "plugin.wasm"; - if (!files[entry]) { + if (files[entry] == null) { diagnostics.push({ severity: "error", code: "PLUGIN_WASM_ENTRY_MISSING", diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index 87f666be..044c47e4 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -293,6 +293,37 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("doctor treats empty runtime files as present", () => { + const ruleFiles = createPluginScaffold({ + id: "acme.redactor", + name: "Redactor", + template: "rule", + }); + ruleFiles["rules/main.json"] = ""; + + const ruleResult = doctorPluginFiles(ruleFiles); + + expect(ruleResult.diagnostics).not.toContainEqual( + expect.objectContaining({ code: "PLUGIN_RULE_FILE_MISSING" }) + ); + + const wasmFiles = createPluginScaffold({ + id: "acme.policy", + name: "Policy", + template: "wasm", + }); + wasmFiles["plugin.wasm"] = ""; + + const wasmResult = doctorPluginFiles(wasmFiles); + + expect(wasmResult.diagnostics).not.toContainEqual( + expect.objectContaining({ code: "PLUGIN_WASM_ENTRY_MISSING" }) + ); + expect(wasmResult.diagnostics).toContainEqual( + expect.objectContaining({ code: "PLUGIN_WASM_POLICY_GATED" }) + ); + }); + 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" })); From c0821242a4c227b336190ec19d3d7d32daf53981 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 00:12:54 +0800 Subject: [PATCH 033/244] fix(plugin-devtools): diagnose malformed runtime shapes --- packages/create-aio-plugin/src/devtools.ts | 32 +++++++++++++++++++ .../create-aio-plugin/src/scaffold.test.ts | 28 ++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 4fded1b4..3f214273 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -277,6 +277,9 @@ export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = const runtimeKind = manifestRuntimeKind(manifest); const runtime = manifest.runtime; + if (!runtimeKind) { + diagnostics.push(runtimeShapeDiagnostic(manifest)); + } if (runtimeKind === "declarativeRules" && runtime.kind === "declarativeRules") { for (const rulePath of runtime.rules) { @@ -352,6 +355,35 @@ function manifestRuntimeKind( return null; } +function runtimeShapeDiagnostic(manifest: Partial): PluginDiagnostic { + const runtime = asRecord(manifest.runtime); + if (!runtime) { + return { + severity: "error", + code: "PLUGIN_INVALID_RUNTIME", + message: "plugin runtime must be an object", + path: "plugin.json#/runtime", + hint: "Set runtime to a Plugin API v1 runtime object.", + }; + } + if (runtime.kind === "declarativeRules") { + return { + severity: "error", + code: "PLUGIN_INVALID_RUNTIME", + message: "declarativeRules runtime requires a rules array", + path: "plugin.json#/runtime", + hint: 'Use runtime: { kind: "declarativeRules", rules: ["rules/main.json"] }.', + }; + } + return { + severity: "error", + code: "PLUGIN_INVALID_RUNTIME", + message: "plugin runtime kind is not supported by create-aio-plugin doctor", + path: "plugin.json#/runtime", + hint: "Use declarativeRules or wasm for community plugin packages.", + }; +} + function doctorManifestSummary( manifest: Partial, runtimeKind: PluginManifest["runtime"]["kind"] | null diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index 044c47e4..e2851085 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -221,13 +221,13 @@ describe("create-aio-plugin scaffold", () => { const result = doctorPluginFiles({ "plugin.json": "{}\n" }); expect(result.ok).toBe(false); - expect(result.diagnostics).toEqual([ + expect(result.diagnostics).toContainEqual( expect.objectContaining({ severity: "error", code: "PLUGIN_INVALID_ID", path: "plugin.json", - }), - ]); + }) + ); }); it("doctor distinguishes empty and non-object plugin.json content", () => { @@ -324,6 +324,28 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("doctor rejects malformed runtime shapes that SDK validation does not catch", () => { + const files = createPluginScaffold({ + id: "acme.redactor", + name: "Redactor", + template: "rule", + }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as Record; + manifest.runtime = { kind: "declarativeRules", rules: "rules/main.json" }; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + + const result = doctorPluginFiles(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_INVALID_RUNTIME", + path: "plugin.json#/runtime", + }) + ); + }); + 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" })); From d55164f114440ead46b2136d9301457417206f3e Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 10:12:56 +0800 Subject: [PATCH 034/244] fix(plugin-devtools): validate declarative rule path entries --- packages/create-aio-plugin/src/devtools.ts | 6 ++++- .../create-aio-plugin/src/scaffold.test.ts | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 3f214273..aad7ac0c 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -346,7 +346,11 @@ function manifestRuntimeKind( manifest: Partial ): PluginManifest["runtime"]["kind"] | null { const runtime = asRecord(manifest.runtime); - if (runtime?.kind === "declarativeRules" && Array.isArray(runtime.rules)) { + if ( + runtime?.kind === "declarativeRules" && + Array.isArray(runtime.rules) && + runtime.rules.every((rulePath) => typeof rulePath === "string" && rulePath.length > 0) + ) { return "declarativeRules"; } if (runtime?.kind === "wasm") { diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index e2851085..bb1aa0c6 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -346,6 +346,29 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("doctor rejects non-string declarative rule paths", () => { + const files = createPluginScaffold({ + id: "acme.redactor", + name: "Redactor", + template: "rule", + }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as Record; + manifest.runtime = { kind: "declarativeRules", rules: [0] }; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + files["0"] = "{}"; + + const result = doctorPluginFiles(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_INVALID_RUNTIME", + path: "plugin.json#/runtime", + }) + ); + }); + 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" })); From 41dd845801365c30e9f0b1d30ab05e683f931f3d Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 10:20:36 +0800 Subject: [PATCH 035/244] fix(plugin-devtools): guard doctor manifest field shapes --- packages/create-aio-plugin/src/devtools.ts | 79 ++++++++++++++++++- .../create-aio-plugin/src/scaffold.test.ts | 53 +++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index aad7ac0c..b3db0c15 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -253,6 +253,8 @@ export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = return { ok: false, diagnostics }; } + diagnostics.push(...manifestShapeDiagnostics(manifest)); + let validation: ValidationResult; try { validation = validateManifest(manifest); @@ -353,12 +355,87 @@ function manifestRuntimeKind( ) { return "declarativeRules"; } - if (runtime?.kind === "wasm") { + if (runtime?.kind === "wasm" && typeof runtime.abiVersion === "string") { return "wasm"; } return null; } +function manifestShapeDiagnostics(manifest: Partial): PluginDiagnostic[] { + const diagnostics: PluginDiagnostic[] = []; + for (const field of ["id", "name", "version", "apiVersion"] as const) { + if (typeof manifest[field] !== "string") { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + message: `${field} must be a string`, + path: `plugin.json#/${field}`, + hint: "Use the Plugin API v1 manifest field types.", + }); + } + } + if (!Array.isArray(manifest.hooks)) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + message: "hooks must be an array", + path: "plugin.json#/hooks", + hint: "Declare at least one Plugin API v1 hook.", + }); + } + if (!Array.isArray(manifest.permissions)) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + message: "permissions must be an array", + path: "plugin.json#/permissions", + hint: "Declare Plugin API v1 permissions as strings.", + }); + } + const compatibility = asRecord(manifest.hostCompatibility); + if (!compatibility) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + message: "hostCompatibility must be an object", + path: "plugin.json#/hostCompatibility", + hint: "Set hostCompatibility.app and hostCompatibility.pluginApi.", + }); + } else { + for (const field of ["app", "pluginApi"] as const) { + if (typeof compatibility[field] !== "string") { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + message: `hostCompatibility.${field} must be a string`, + path: `plugin.json#/hostCompatibility/${field}`, + hint: "Use Plugin API v1 compatibility range strings.", + }); + } + } + } + if (manifest.entry != null && typeof manifest.entry !== "string") { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + message: "entry must be a string when present", + path: "plugin.json#/entry", + hint: "Use a package-relative entry path.", + }); + } + const runtime = asRecord(manifest.runtime); + if (runtime?.kind === "wasm" && typeof runtime.abiVersion !== "string") { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_RUNTIME", + message: "wasm runtime requires a string abiVersion", + path: "plugin.json#/runtime/abiVersion", + hint: 'Use runtime: { kind: "wasm", abiVersion: "1.0.0" }.', + }); + } + return diagnostics; +} + function runtimeShapeDiagnostic(manifest: Partial): PluginDiagnostic { const runtime = asRecord(manifest.runtime); if (!runtime) { diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index bb1aa0c6..727ec3be 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -369,6 +369,59 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("doctor rejects malformed manifest field types that SDK validation can coerce", () => { + const files = createPluginScaffold({ + id: "acme.redactor", + name: "Redactor", + template: "rule", + }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as Record; + manifest.name = 123; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + + const result = doctorPluginFiles(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + path: "plugin.json#/name", + }) + ); + }); + + it("doctor rejects malformed wasm runtime metadata", () => { + const files = createPluginScaffold({ + id: "acme.policy", + name: "Policy", + template: "wasm", + }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as Record; + manifest.runtime = { kind: "wasm", abiVersion: ["1.0.0"] }; + manifest.entry = 42; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + files["42"] = ""; + + const result = doctorPluginFiles(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_INVALID_RUNTIME", + path: "plugin.json#/runtime/abiVersion", + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + path: "plugin.json#/entry", + }) + ); + }); + 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" })); From 94cf4c02721f5bc2f6bb05d6cf6d666d96d394e4 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 10:33:22 +0800 Subject: [PATCH 036/244] fix(plugin-devtools): harden doctor file and metadata checks --- packages/create-aio-plugin/src/devtools.ts | 79 ++++++++++++++- .../create-aio-plugin/src/scaffold.test.ts | 95 +++++++++++++++++++ 2 files changed, 172 insertions(+), 2 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index b3db0c15..a1acea19 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -285,7 +285,7 @@ export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = if (runtimeKind === "declarativeRules" && runtime.kind === "declarativeRules") { for (const rulePath of runtime.rules) { - if (files[rulePath] == null) { + if (!hasPluginFile(files, rulePath)) { diagnostics.push({ severity: "error", code: "PLUGIN_RULE_FILE_MISSING", @@ -299,7 +299,7 @@ export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = if (runtimeKind === "wasm") { const entry = manifest.entry ?? "plugin.wasm"; - if (files[entry] == null) { + if (!hasPluginFile(files, entry)) { diagnostics.push({ severity: "error", code: "PLUGIN_WASM_ENTRY_MISSING", @@ -337,6 +337,10 @@ function hasErrorDiagnostics(diagnostics: readonly PluginDiagnostic[]): boolean return diagnostics.some((diagnostic) => diagnostic.severity === "error"); } +function hasPluginFile(files: ScaffoldFiles, path: string): boolean { + return Object.prototype.hasOwnProperty.call(files, path); +} + function strictRuleDiagnostics( _files: ScaffoldFiles, _manifest: PluginManifest @@ -382,6 +386,51 @@ function manifestShapeDiagnostics(manifest: Partial): PluginDiag path: "plugin.json#/hooks", hint: "Declare at least one Plugin API v1 hook.", }); + } else { + manifest.hooks.forEach((hook, index) => { + const hookRecord = asRecord(hook); + if (!hookRecord) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + message: "hook entries must be objects", + path: `plugin.json#/hooks/${index}`, + hint: "Declare hooks as Plugin API v1 hook objects.", + }); + return; + } + if (typeof hookRecord.name !== "string") { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + message: "hook name must be a string", + path: `plugin.json#/hooks/${index}/name`, + hint: "Use a Plugin API v1 hook name.", + }); + } + if (hookRecord.priority != null && typeof hookRecord.priority !== "number") { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + message: "hook priority must be a number when present", + path: `plugin.json#/hooks/${index}/priority`, + hint: "Use a numeric hook priority.", + }); + } + if ( + hookRecord.failurePolicy != null && + hookRecord.failurePolicy !== "fail-open" && + hookRecord.failurePolicy !== "fail-closed" + ) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + message: "hook failurePolicy must be fail-open or fail-closed when present", + path: `plugin.json#/hooks/${index}/failurePolicy`, + hint: "Use fail-open or fail-closed.", + }); + } + }); } if (!Array.isArray(manifest.permissions)) { diagnostics.push({ @@ -413,6 +462,19 @@ function manifestShapeDiagnostics(manifest: Partial): PluginDiag }); } } + if ( + compatibility.platforms != null && + (!Array.isArray(compatibility.platforms) || + !compatibility.platforms.every((platform) => typeof platform === "string")) + ) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_MANIFEST", + message: "hostCompatibility.platforms must be an array of strings when present", + path: "plugin.json#/hostCompatibility/platforms", + hint: "Use platform names as strings.", + }); + } } if (manifest.entry != null && typeof manifest.entry !== "string") { diagnostics.push({ @@ -433,6 +495,19 @@ function manifestShapeDiagnostics(manifest: Partial): PluginDiag hint: 'Use runtime: { kind: "wasm", abiVersion: "1.0.0" }.', }); } + if ( + runtime?.kind === "wasm" && + runtime.memoryLimitBytes != null && + typeof runtime.memoryLimitBytes !== "number" + ) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_INVALID_RUNTIME", + message: "wasm memoryLimitBytes must be a number when present", + path: "plugin.json#/runtime/memoryLimitBytes", + hint: "Use a byte count number.", + }); + } return diagnostics; } diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index 727ec3be..df68e382 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -293,6 +293,47 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("doctor only treats own runtime file entries as present", () => { + const ruleFiles = createPluginScaffold({ + id: "acme.redactor", + name: "Redactor", + template: "rule", + }); + delete ruleFiles["rules/main.json"]; + const ruleManifest = JSON.parse(ruleFiles["plugin.json"] ?? "{}") as Record; + ruleManifest.runtime = { kind: "declarativeRules", rules: ["toString"] }; + ruleFiles["plugin.json"] = `${JSON.stringify(ruleManifest, null, 2)}\n`; + + const ruleResult = doctorPluginFiles(ruleFiles); + + expect(ruleResult.ok).toBe(false); + expect(ruleResult.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_FILE_MISSING", + path: "toString", + }) + ); + + const wasmFiles = createPluginScaffold({ + id: "acme.policy", + name: "Policy", + template: "wasm", + }); + const wasmManifest = JSON.parse(wasmFiles["plugin.json"] ?? "{}") as Record; + wasmManifest.entry = "toString"; + wasmFiles["plugin.json"] = `${JSON.stringify(wasmManifest, null, 2)}\n`; + + const wasmResult = doctorPluginFiles(wasmFiles); + + expect(wasmResult.ok).toBe(false); + expect(wasmResult.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_WASM_ENTRY_MISSING", + path: "toString", + }) + ); + }); + it("doctor treats empty runtime files as present", () => { const ruleFiles = createPluginScaffold({ id: "acme.redactor", @@ -422,6 +463,60 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("doctor rejects malformed optional manifest metadata", () => { + const files = createPluginScaffold({ + id: "acme.redactor", + name: "Redactor", + template: "rule", + }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as Record; + manifest.hooks = [{ name: "gateway.request.afterBodyRead", priority: "high" }]; + manifest.hostCompatibility = { + app: ">=0.56.0 <1.0.0", + pluginApi: "^1.0.0", + platforms: "linux", + }; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + + const result = doctorPluginFiles(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_INVALID_MANIFEST", + path: "plugin.json#/hooks/0/priority", + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_INVALID_MANIFEST", + path: "plugin.json#/hostCompatibility/platforms", + }) + ); + }); + + it("doctor rejects malformed wasm memory limits", () => { + const files = createPluginScaffold({ + id: "acme.policy", + name: "Policy", + template: "wasm", + }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as Record; + manifest.runtime = { kind: "wasm", abiVersion: "1.0.0", memoryLimitBytes: "16MB" }; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + files["plugin.wasm"] = ""; + + const result = doctorPluginFiles(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_INVALID_RUNTIME", + path: "plugin.json#/runtime/memoryLimitBytes", + }) + ); + }); + 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" })); From 1c47926dfad63f87cf2ede7b3333d09451535d07 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 10:37:41 +0800 Subject: [PATCH 037/244] fix(plugin-devtools): normalize malformed wasm entry checks --- packages/create-aio-plugin/src/devtools.ts | 2 +- .../create-aio-plugin/src/scaffold.test.ts | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index a1acea19..8a9270a6 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -298,7 +298,7 @@ export function doctorPluginFiles(files: ScaffoldFiles, options: DoctorOptions = } if (runtimeKind === "wasm") { - const entry = manifest.entry ?? "plugin.wasm"; + const entry = typeof manifest.entry === "string" ? manifest.entry : "plugin.wasm"; if (!hasPluginFile(files, entry)) { diagnostics.push({ severity: "error", diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index df68e382..64587171 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -517,6 +517,34 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("doctor does not use malformed wasm entry values as file paths", () => { + const files = createPluginScaffold({ + id: "acme.policy", + name: "Policy", + template: "wasm", + }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as Record; + manifest.entry = 42; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + files["42"] = ""; + + const result = doctorPluginFiles(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_INVALID_MANIFEST", + path: "plugin.json#/entry", + }) + ); + expect(result.diagnostics).not.toContainEqual( + expect.objectContaining({ + code: "PLUGIN_WASM_ENTRY_MISSING", + path: 42, + }) + ); + }); + 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" })); From 963e1914767ebd7288f2ddc0be21a7d391e341d8 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 10:47:08 +0800 Subject: [PATCH 038/244] feat(plugin-devtools): add strict plugin validation --- packages/create-aio-plugin/src/devtools.ts | 184 +++++++++++++++++- .../create-aio-plugin/src/scaffold.test.ts | 96 +++++++++ 2 files changed, 274 insertions(+), 6 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 8a9270a6..61c77c5a 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -50,6 +50,10 @@ export type DoctorResult = { }; }; +export type StrictValidationResult = + | { ok: true; diagnostics: PluginDiagnostic[] } + | { ok: false; error: { code: string; message: string }; diagnostics: PluginDiagnostic[] }; + type DoctorOptions = { strict?: boolean; }; @@ -88,8 +92,21 @@ export function runCreateAioPluginCli(args: string[], cwd: string, io: CliIo = c } if (commandOrId === "validate") { + const strict = firstArg === "--strict"; + const pluginDir = strict ? secondArg : firstArg; try { - io.log(JSON.stringify(validatePluginDirectory(resolve(cwd, firstArg ?? ".")))); + 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; + } + io.log(JSON.stringify(validatePluginDirectory(root))); return 0; } catch (error) { io.error(`failed to validate plugin directory: ${errorMessage(error)}`); @@ -209,6 +226,23 @@ export function validatePluginDirectory(root: string): ValidationResult { return validatePluginFiles(readPluginDirectory(root)); } +export function validatePluginDirectoryStrict(root: string): StrictValidationResult { + return validatePluginFilesStrict(readPluginDirectory(root)); +} + +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, + }; +} + export function doctorPluginDirectory(root: string, options: DoctorOptions = {}): DoctorResult { return doctorPluginFiles(readPluginDirectory(root), options); } @@ -341,11 +375,149 @@ function hasPluginFile(files: ScaffoldFiles, path: string): boolean { return Object.prototype.hasOwnProperty.call(files, path); } -function strictRuleDiagnostics( - _files: ScaffoldFiles, - _manifest: PluginManifest -): PluginDiagnostic[] { - return []; +const RULE_TARGET_FIELDS_BY_HOOK: Record = { + "gateway.request.afterBodyRead": ["request.body"], + "gateway.request.beforeSend": ["request.body"], + "gateway.response.after": ["response.body"], + "gateway.response.chunk": ["stream.chunk"], + "gateway.error": ["request.body", "response.body"], + "log.beforePersist": ["log.message"], +}; + +function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): PluginDiagnostic[] { + const runtime = asRecord(manifest.runtime); + if ( + runtime?.kind !== "declarativeRules" || + !Array.isArray(runtime.rules) || + !runtime.rules.every((rulePath) => typeof rulePath === "string") + ) { + return []; + } + + const diagnostics: PluginDiagnostic[] = []; + const declaredHooks = new Set( + Array.isArray(manifest.hooks) + ? manifest.hooks + .map((hook) => asRecord(hook)?.name) + .filter((name): name is string => typeof name === "string") + : [] + ); + const grantedPermissions = new Set( + Array.isArray(manifest.permissions) + ? manifest.permissions.flatMap((permission) => + typeof permission === "string" ? [permission] : [] + ) + : [] + ); + + for (const rulePath of runtime.rules) { + const text = files[rulePath]; + if (!text) continue; + + let document: { rules?: unknown[] }; + try { + document = JSON.parse(text) as { rules?: unknown[] }; + } catch (error) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_FILE_INVALID_JSON", + message: `rule file is not valid JSON: ${errorMessage(error)}`, + path: rulePath, + hint: "Fix the rule JSON before replaying or packing the plugin.", + }); + continue; + } + + if (!Array.isArray(document.rules)) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULES_MISSING_ARRAY", + message: "rule document must contain a rules array", + path: `${rulePath}#/rules`, + hint: 'Use { "rules": [...] } as the rule document shape.', + }); + continue; + } + + document.rules.forEach((rawRule, index) => { + const rule = asRecord(rawRule); + if (!rule) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_INVALID", + message: "rule entry must be an object", + path: `${rulePath}#/rules/${index}`, + hint: "Replace the entry with an object containing hook, target, match, and action.", + }); + return; + } + + const hook = typeof rule.hook === "string" ? rule.hook : ""; + if (!hook) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_HOOK_MISSING", + message: "rule hook is required", + path: `${rulePath}#/rules/${index}/hook`, + hint: "Set hook to one of the hooks declared in plugin.json.", + }); + } else if (!declaredHooks.has(hook)) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_HOOK_NOT_DECLARED", + message: `rule hook is not declared in plugin.json: ${hook}`, + path: `${rulePath}#/rules/${index}/hook`, + hint: "Add the hook to manifest.hooks or change the rule hook.", + }); + } + + const target = asRecord(rule.target); + const action = asRecord(rule.action); + const targetField = typeof target?.field === "string" ? target.field : "request.body"; + const actionKind = typeof action?.kind === "string" ? action.kind : ""; + const allowedFields = RULE_TARGET_FIELDS_BY_HOOK[hook] ?? []; + if (hook && allowedFields.length > 0 && !allowedFields.includes(targetField)) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_TARGET_INCOMPATIBLE_WITH_HOOK", + message: `target field ${targetField} is not compatible with hook ${hook}`, + path: `${rulePath}#/rules/${index}/target/field`, + hint: `Use one of: ${allowedFields.join(", ")}.`, + }); + } + + for (const permission of permissionsForRuleTarget(targetField, actionKind)) { + if (!grantedPermissions.has(permission)) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_PERMISSION_MISMATCH", + message: `rule targeting ${targetField} with action ${ + actionKind || "unknown" + } requires ${permission}`, + path: `${rulePath}#/rules/${index}`, + hint: `Add ${permission} to manifest.permissions or change the rule target/action.`, + }); + } + } + }); + } + + return diagnostics; +} + +function permissionsForRuleTarget(field: string, actionKind: string): string[] { + const mutates = actionKind === "replace" || actionKind === "appendMessage"; + switch (field) { + case "response.body": + return mutates ? ["response.body.read", "response.body.write"] : ["response.body.read"]; + case "stream.chunk": + return mutates ? ["stream.inspect", "stream.modify"] : ["stream.inspect"]; + case "log.message": + return ["log.redact"]; + case "request.body": + default: + return mutates ? ["request.body.read", "request.body.write"] : ["request.body.read"]; + } } function manifestRuntimeKind( diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index 64587171..53965145 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -17,6 +17,7 @@ import { signPackage, validatePluginDirectory, validatePluginFiles, + validatePluginFilesStrict, verifyPackage, } from "./devtools"; @@ -204,6 +205,101 @@ describe("create-aio-plugin scaffold", () => { expect(result).toEqual({ ok: true }); }); + it("validate strict rejects malformed declarative rule documents", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + files["rules/main.json"] = "{ bad json"; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_RULE_FILE_INVALID_JSON", + path: "rules/main.json", + }) + ); + }); + + it("validate strict rejects rules whose hook is not declared by the manifest", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as { + hooks: Array<{ name: string; priority?: number }>; + }; + manifest.hooks = [{ name: "gateway.response.after", priority: 100 }]; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_RULE_HOOK_NOT_DECLARED", + path: "rules/main.json#/rules/0/hook", + }) + ); + }); + + it("validate strict rejects missing permissions for mutating declarative rules", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as { + permissions: string[]; + }; + manifest.permissions = ["request.body.read"]; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_RULE_PERMISSION_MISMATCH", + message: expect.stringContaining("request.body.write"), + }) + ); + }); + + it("legacy validate remains manifest-only while validate strict reports package errors", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + delete files["rules/main.json"]; + + expect(validatePluginFiles(files)).toEqual({ ok: true }); + expect(validatePluginFilesStrict(files)).toMatchObject({ + ok: false, + diagnostics: [expect.objectContaining({ code: "PLUGIN_RULE_FILE_MISSING" })], + }); + }); + + it("validate strict command preserves the old validate command shape unless strict is requested", () => { + const root = mkdtempSync(join(tmpdir(), "aio-plugin-strict-")); + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + delete files["rules/main.json"]; + writeScaffold(root, files); + const normalOutput: string[] = []; + const strictOutput: string[] = []; + + expect( + runCreateAioPluginCli(["validate", root], process.cwd(), { + log: (line) => normalOutput.push(line), + error: (line) => normalOutput.push(line), + }) + ).toBe(0); + expect(JSON.parse(normalOutput[0] ?? "{}")).toEqual({ ok: true }); + + 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_RULE_FILE_MISSING" })], + }); + }); + it("doctor reports a structured error when plugin.json is missing", () => { const result = doctorPluginFiles({}); From e7df3f45aa0bee216e0f8f268f1cc843566d3fd1 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 10:52:41 +0800 Subject: [PATCH 039/244] fix(plugin-devtools): classify empty strict rule documents --- packages/create-aio-plugin/src/devtools.ts | 10 +++--- .../create-aio-plugin/src/scaffold.test.ts | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 61c77c5a..9230fa0e 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -411,12 +411,12 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): ); for (const rulePath of runtime.rules) { - const text = files[rulePath]; - if (!text) continue; + if (!hasPluginFile(files, rulePath)) continue; + const text = files[rulePath] ?? ""; - let document: { rules?: unknown[] }; + let document: Record | null; try { - document = JSON.parse(text) as { rules?: unknown[] }; + document = asRecord(JSON.parse(text) as unknown); } catch (error) { diagnostics.push({ severity: "error", @@ -428,7 +428,7 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): continue; } - if (!Array.isArray(document.rules)) { + if (!document || !Array.isArray(document.rules)) { diagnostics.push({ severity: "error", code: "PLUGIN_RULES_MISSING_ARRAY", diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index 53965145..f044ff2e 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -221,6 +221,38 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("validate strict rejects empty declarative rule documents", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + files["rules/main.json"] = ""; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_RULE_FILE_INVALID_JSON", + path: "rules/main.json", + }) + ); + }); + + it("validate strict rejects non-object declarative rule documents", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + files["rules/main.json"] = "null"; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_RULES_MISSING_ARRAY", + path: "rules/main.json#/rules", + }) + ); + }); + it("validate strict rejects rules whose hook is not declared by the manifest", () => { const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); const manifest = JSON.parse(files["plugin.json"] ?? "{}") as { From d6e8f5ded0a067b511758e12a25b032ca1b84f96 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 11:08:46 +0800 Subject: [PATCH 040/244] fix(plugin-devtools): align strict rule permissions with hooks --- packages/create-aio-plugin/src/devtools.ts | 6 +- .../create-aio-plugin/src/scaffold.test.ts | 58 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 9230fa0e..743c6de4 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -486,7 +486,7 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): }); } - for (const permission of permissionsForRuleTarget(targetField, actionKind)) { + for (const permission of permissionsForRuleTarget(hook, targetField, actionKind)) { if (!grantedPermissions.has(permission)) { diagnostics.push({ severity: "error", @@ -505,10 +505,11 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): return diagnostics; } -function permissionsForRuleTarget(field: string, actionKind: string): string[] { +function permissionsForRuleTarget(hook: string, field: string, actionKind: string): string[] { const mutates = actionKind === "replace" || actionKind === "appendMessage"; switch (field) { case "response.body": + if (mutates && hook === "gateway.error") return ["response.body.write"]; return mutates ? ["response.body.read", "response.body.write"] : ["response.body.read"]; case "stream.chunk": return mutates ? ["stream.inspect", "stream.modify"] : ["stream.inspect"]; @@ -516,6 +517,7 @@ function permissionsForRuleTarget(field: string, actionKind: string): string[] { return ["log.redact"]; case "request.body": default: + if (mutates && hook === "gateway.request.beforeSend") return ["request.body.write"]; return mutates ? ["request.body.read", "request.body.write"] : ["request.body.read"]; } } diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index f044ff2e..651fabc6 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -293,6 +293,64 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("validate strict allows host-compatible write-only request hooks", () => { + const files = rulePluginFilesWithRule({ + hook: "gateway.request.beforeSend", + target: { field: "request.body" }, + }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as { + permissions: string[]; + }; + manifest.permissions = ["request.body.write"]; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.diagnostics).not.toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_PERMISSION_MISMATCH", + }) + ); + }); + + it("validate strict allows host-compatible write-only gateway error response hooks", () => { + const files = rulePluginFilesWithRule({ + hook: "gateway.error", + target: { field: "response.body" }, + }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as { + permissions: string[]; + }; + manifest.permissions = ["response.body.write"]; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.diagnostics).not.toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_PERMISSION_MISMATCH", + }) + ); + }); + + it("validate strict rejects rule targets that do not match the hook", () => { + const files = rulePluginFilesWithRule({ + hook: "gateway.response.after", + target: { field: "request.body" }, + }); + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_RULE_TARGET_INCOMPATIBLE_WITH_HOOK", + path: "rules/main.json#/rules/0/target/field", + }) + ); + }); + it("legacy validate remains manifest-only while validate strict reports package errors", () => { const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); delete files["rules/main.json"]; From 8c59920976071133ec01a162d0b5f95acbcbb8cc Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 11:15:14 +0800 Subject: [PATCH 041/244] fix(plugin-devtools): validate strict rule structure --- packages/create-aio-plugin/src/devtools.ts | 121 +++++++++++++++++- .../create-aio-plugin/src/scaffold.test.ts | 108 ++++++++++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 743c6de4..fb09bc6a 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -472,11 +472,59 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): } const target = asRecord(rule.target); + if (!target) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_TARGET_MISSING", + message: "rule target must be an object", + path: `${rulePath}#/rules/${index}/target`, + hint: "Set target.field to the hook-visible field the rule should inspect.", + }); + } + const matcher = asRecord(rule.matcher) ?? asRecord(rule.match); + if (!matcher) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_MATCHER_MISSING", + message: "rule match must be an object", + path: `${rulePath}#/rules/${index}/match`, + hint: "Set match.regex to the pattern the rule should evaluate.", + }); + } else if (typeof matcher.regex !== "string") { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_MATCHER_INVALID", + message: "rule match.regex must be a string", + path: `${rulePath}#/rules/${index}/match/regex`, + hint: "Use a JavaScript-compatible regex string.", + }); + } const action = asRecord(rule.action); - const targetField = typeof target?.field === "string" ? target.field : "request.body"; + if (!action) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_ACTION_MISSING", + message: "rule action must be an object", + path: `${rulePath}#/rules/${index}/action`, + hint: "Set action.kind and its required payload.", + }); + } + const targetField = typeof target?.field === "string" ? target.field : ""; + if (target && !targetField) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_TARGET_INVALID", + message: "rule target.field must be a string", + path: `${rulePath}#/rules/${index}/target/field`, + hint: "Use a hook-visible target field such as request.body.", + }); + } const actionKind = typeof action?.kind === "string" ? action.kind : ""; + diagnostics.push(...ruleActionDiagnostics(action, actionKind, rulePath, index)); const allowedFields = RULE_TARGET_FIELDS_BY_HOOK[hook] ?? []; + let targetCompatible = true; if (hook && allowedFields.length > 0 && !allowedFields.includes(targetField)) { + targetCompatible = false; diagnostics.push({ severity: "error", code: "PLUGIN_RULE_TARGET_INCOMPATIBLE_WITH_HOOK", @@ -486,6 +534,8 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): }); } + if (!targetCompatible || !targetField) return; + for (const permission of permissionsForRuleTarget(hook, targetField, actionKind)) { if (!grantedPermissions.has(permission)) { diagnostics.push({ @@ -522,6 +572,75 @@ function permissionsForRuleTarget(hook: string, field: string, actionKind: strin } } +function ruleActionDiagnostics( + action: Record | null, + actionKind: string, + rulePath: string, + index: number +): PluginDiagnostic[] { + if (!action) return []; + const path = `${rulePath}#/rules/${index}/action`; + if (!actionKind) { + return [ + { + severity: "error", + code: "PLUGIN_RULE_ACTION_INVALID", + message: "rule action.kind must be a string", + path: `${path}/kind`, + hint: "Use replace, block, warn, or appendMessage.", + }, + ]; + } + if (actionKind === "replace" && typeof action.replacement !== "string") { + return [ + { + severity: "error", + code: "PLUGIN_RULE_ACTION_INVALID", + message: "replace action requires a string replacement", + path: `${path}/replacement`, + hint: "Set action.replacement to the replacement text.", + }, + ]; + } + if (actionKind === "block" && typeof action.reason !== "string") { + return [ + { + severity: "error", + code: "PLUGIN_RULE_ACTION_INVALID", + message: "block action requires a string reason", + path: `${path}/reason`, + hint: "Set action.reason to the block reason.", + }, + ]; + } + if (actionKind === "warn" && typeof action.message !== "string") { + return [ + { + severity: "error", + code: "PLUGIN_RULE_ACTION_INVALID", + message: "warn action requires a string message", + path: `${path}/message`, + hint: "Set action.message to the warning message.", + }, + ]; + } + if ( + actionKind === "appendMessage" && + (typeof action.role !== "string" || typeof action.content !== "string") + ) { + return [ + { + severity: "error", + code: "PLUGIN_RULE_ACTION_INVALID", + message: "appendMessage action requires string role and content", + path, + hint: "Set action.role and action.content.", + }, + ]; + } + return []; +} + function manifestRuntimeKind( manifest: Partial ): PluginManifest["runtime"]["kind"] | null { diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index 651fabc6..a1b27c66 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -293,6 +293,107 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("validate strict rejects rules with missing target, matcher, or action", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + files["rules/main.json"] = `${JSON.stringify( + { + rules: [ + { + hook: "gateway.request.afterBodyRead", + match: { regex: "SECRET" }, + action: { kind: "replace", replacement: "[x]" }, + }, + { + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + action: { kind: "replace", replacement: "[x]" }, + }, + { + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "SECRET" }, + }, + ], + }, + null, + 2 + )}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_TARGET_MISSING", + path: "rules/main.json#/rules/0/target", + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_MISSING", + path: "rules/main.json#/rules/1/match", + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_ACTION_MISSING", + path: "rules/main.json#/rules/2/action", + }) + ); + }); + + it("validate strict rejects malformed matcher and action payloads", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + files["rules/main.json"] = `${JSON.stringify( + { + rules: [ + { + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: 7 }, + action: { kind: "replace", replacement: "[x]" }, + }, + { + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "SECRET" }, + action: { kind: "replace" }, + }, + { + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "SECRET" }, + action: { kind: "warn" }, + }, + ], + }, + null, + 2 + )}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_INVALID", + path: "rules/main.json#/rules/0/match/regex", + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_ACTION_INVALID", + path: "rules/main.json#/rules/1/action/replacement", + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_ACTION_INVALID", + path: "rules/main.json#/rules/2/action/message", + }) + ); + }); + it("validate strict allows host-compatible write-only request hooks", () => { const files = rulePluginFilesWithRule({ hook: "gateway.request.beforeSend", @@ -306,6 +407,7 @@ describe("create-aio-plugin scaffold", () => { const result = validatePluginFilesStrict(files); + expect(result.ok).toBe(true); expect(result.diagnostics).not.toContainEqual( expect.objectContaining({ code: "PLUGIN_RULE_PERMISSION_MISMATCH", @@ -326,6 +428,7 @@ describe("create-aio-plugin scaffold", () => { const result = validatePluginFilesStrict(files); + expect(result.ok).toBe(true); expect(result.diagnostics).not.toContainEqual( expect.objectContaining({ code: "PLUGIN_RULE_PERMISSION_MISMATCH", @@ -349,6 +452,11 @@ describe("create-aio-plugin scaffold", () => { path: "rules/main.json#/rules/0/target/field", }) ); + expect(result.diagnostics).not.toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_PERMISSION_MISMATCH", + }) + ); }); it("legacy validate remains manifest-only while validate strict reports package errors", () => { From 6ea9ed77ac9a6659d62eaa55b164d5c6b717b7f5 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 11:22:50 +0800 Subject: [PATCH 042/244] fix(plugin-devtools): refine strict action diagnostics --- packages/create-aio-plugin/src/devtools.ts | 13 ++++++- .../create-aio-plugin/src/scaffold.test.ts | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index fb09bc6a..3d99e5af 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -523,7 +523,7 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): diagnostics.push(...ruleActionDiagnostics(action, actionKind, rulePath, index)); const allowedFields = RULE_TARGET_FIELDS_BY_HOOK[hook] ?? []; let targetCompatible = true; - if (hook && allowedFields.length > 0 && !allowedFields.includes(targetField)) { + if (hook && targetField && allowedFields.length > 0 && !allowedFields.includes(targetField)) { targetCompatible = false; diagnostics.push({ severity: "error", @@ -591,6 +591,17 @@ function ruleActionDiagnostics( }, ]; } + if (!["replace", "block", "warn", "appendMessage"].includes(actionKind)) { + return [ + { + severity: "error", + code: "PLUGIN_RULE_ACTION_INVALID", + message: `unsupported rule action kind: ${actionKind}`, + path: `${path}/kind`, + hint: "Use replace, block, warn, or appendMessage.", + }, + ]; + } if (actionKind === "replace" && typeof action.replacement !== "string") { return [ { diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index a1b27c66..eabf5fd2 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -328,6 +328,12 @@ describe("create-aio-plugin scaffold", () => { path: "rules/main.json#/rules/0/target", }) ); + expect(result.diagnostics).not.toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_TARGET_INCOMPATIBLE_WITH_HOOK", + path: "rules/main.json#/rules/0/target/field", + }) + ); expect(result.diagnostics).toContainEqual( expect.objectContaining({ code: "PLUGIN_RULE_MATCHER_MISSING", @@ -394,6 +400,34 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("validate strict rejects unsupported action kinds", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + files["rules/main.json"] = `${JSON.stringify( + { + rules: [ + { + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "SECRET" }, + action: { kind: "drop" }, + }, + ], + }, + null, + 2 + )}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_ACTION_INVALID", + path: "rules/main.json#/rules/0/action/kind", + }) + ); + }); + it("validate strict allows host-compatible write-only request hooks", () => { const files = rulePluginFilesWithRule({ hook: "gateway.request.beforeSend", From 8381e66cbe21b1397c5086b443861254a38eb6a4 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 11:39:20 +0800 Subject: [PATCH 043/244] fix(plugin-devtools): align strict rules with host runtime --- packages/create-aio-plugin/src/devtools.ts | 25 +++-- .../create-aio-plugin/src/scaffold.test.ts | 106 +++++++++++++++++- 2 files changed, 117 insertions(+), 14 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 3d99e5af..877456d6 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -380,7 +380,7 @@ const RULE_TARGET_FIELDS_BY_HOOK: Record = { "gateway.request.beforeSend": ["request.body"], "gateway.response.after": ["response.body"], "gateway.response.chunk": ["stream.chunk"], - "gateway.error": ["request.body", "response.body"], + "gateway.error": ["response.body"], "log.beforePersist": ["log.message"], }; @@ -452,6 +452,16 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): return; } + if (typeof rule.id !== "string" || rule.id.length === 0) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_ID_MISSING", + message: "rule id is required", + path: `${rulePath}#/rules/${index}/id`, + hint: "Set id to a stable string so host diagnostics can identify this rule.", + }); + } + const hook = typeof rule.hook === "string" ? rule.hook : ""; if (!hook) { diagnostics.push({ @@ -481,7 +491,7 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): hint: "Set target.field to the hook-visible field the rule should inspect.", }); } - const matcher = asRecord(rule.matcher) ?? asRecord(rule.match); + const matcher = asRecord(rule.match); if (!matcher) { diagnostics.push({ severity: "error", @@ -520,7 +530,8 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): }); } const actionKind = typeof action?.kind === "string" ? action.kind : ""; - diagnostics.push(...ruleActionDiagnostics(action, actionKind, rulePath, index)); + const actionDiagnostics = ruleActionDiagnostics(action, actionKind, rulePath, index); + diagnostics.push(...actionDiagnostics); const allowedFields = RULE_TARGET_FIELDS_BY_HOOK[hook] ?? []; let targetCompatible = true; if (hook && targetField && allowedFields.length > 0 && !allowedFields.includes(targetField)) { @@ -534,9 +545,9 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): }); } - if (!targetCompatible || !targetField) return; + if (!targetCompatible || !targetField || !action || actionDiagnostics.length > 0) return; - for (const permission of permissionsForRuleTarget(hook, targetField, actionKind)) { + for (const permission of permissionsForRuleTarget(targetField, actionKind)) { if (!grantedPermissions.has(permission)) { diagnostics.push({ severity: "error", @@ -555,11 +566,10 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): return diagnostics; } -function permissionsForRuleTarget(hook: string, field: string, actionKind: string): string[] { +function permissionsForRuleTarget(field: string, actionKind: string): string[] { const mutates = actionKind === "replace" || actionKind === "appendMessage"; switch (field) { case "response.body": - if (mutates && hook === "gateway.error") return ["response.body.write"]; return mutates ? ["response.body.read", "response.body.write"] : ["response.body.read"]; case "stream.chunk": return mutates ? ["stream.inspect", "stream.modify"] : ["stream.inspect"]; @@ -567,7 +577,6 @@ function permissionsForRuleTarget(hook: string, field: string, actionKind: strin return ["log.redact"]; case "request.body": default: - if (mutates && hook === "gateway.request.beforeSend") return ["request.body.write"]; return mutates ? ["request.body.read", "request.body.write"] : ["request.body.read"]; } } diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index eabf5fd2..c82e907b 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -293,7 +293,7 @@ describe("create-aio-plugin scaffold", () => { ); }); - it("validate strict rejects rules with missing target, matcher, or action", () => { + it("validate strict rejects rules with missing id, target, match, or action", () => { const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); files["rules/main.json"] = `${JSON.stringify( { @@ -304,11 +304,13 @@ describe("create-aio-plugin scaffold", () => { action: { kind: "replace", replacement: "[x]" }, }, { + id: "missing-match", hook: "gateway.request.afterBodyRead", target: { field: "request.body" }, action: { kind: "replace", replacement: "[x]" }, }, { + id: "missing-action", hook: "gateway.request.afterBodyRead", target: { field: "request.body" }, match: { regex: "SECRET" }, @@ -322,6 +324,12 @@ describe("create-aio-plugin scaffold", () => { const result = validatePluginFilesStrict(files); expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_ID_MISSING", + path: "rules/main.json#/rules/0/id", + }) + ); expect(result.diagnostics).toContainEqual( expect.objectContaining({ code: "PLUGIN_RULE_TARGET_MISSING", @@ -348,6 +356,29 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("validate strict rejects the legacy matcher alias because the host runtime requires match", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + const document = JSON.parse(files["rules/main.json"] ?? "{}") as { + rules?: Array>; + }; + const rule = document.rules?.[0]; + if (rule) { + rule.matcher = rule.match; + delete rule.match; + } + files["rules/main.json"] = `${JSON.stringify(document, null, 2)}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_MISSING", + path: "rules/main.json#/rules/0/match", + }) + ); + }); + it("validate strict rejects malformed matcher and action payloads", () => { const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); files["rules/main.json"] = `${JSON.stringify( @@ -428,7 +459,7 @@ describe("create-aio-plugin scaffold", () => { ); }); - it("validate strict allows host-compatible write-only request hooks", () => { + it("validate strict rejects write-only request rules because matching needs body read access", () => { const files = rulePluginFilesWithRule({ hook: "gateway.request.beforeSend", target: { field: "request.body" }, @@ -441,15 +472,16 @@ describe("create-aio-plugin scaffold", () => { const result = validatePluginFilesStrict(files); - expect(result.ok).toBe(true); - expect(result.diagnostics).not.toContainEqual( + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( expect.objectContaining({ code: "PLUGIN_RULE_PERMISSION_MISMATCH", + message: expect.stringContaining("request.body.read"), }) ); }); - it("validate strict allows host-compatible write-only gateway error response hooks", () => { + it("validate strict rejects write-only gateway error response rules because matching needs body read access", () => { const files = rulePluginFilesWithRule({ hook: "gateway.error", target: { field: "response.body" }, @@ -462,7 +494,69 @@ describe("create-aio-plugin scaffold", () => { const result = validatePluginFilesStrict(files); - expect(result.ok).toBe(true); + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_PERMISSION_MISMATCH", + message: expect.stringContaining("response.body.read"), + }) + ); + }); + + it("validate strict rejects request body targets on gateway error hooks", () => { + const files = rulePluginFilesWithRule({ + hook: "gateway.error", + target: { field: "request.body" }, + }); + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_RULE_TARGET_INCOMPATIBLE_WITH_HOOK", + path: "rules/main.json#/rules/0/target/field", + hint: "Use one of: response.body.", + }) + ); + }); + + it("validate strict skips permission mismatch noise when action is invalid", () => { + const files = rulePluginFilesWithRule({ + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as { + permissions: string[]; + }; + manifest.permissions = []; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + files["rules/main.json"] = `${JSON.stringify( + { + rules: [ + { + id: "invalid-action", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "SECRET" }, + action: { kind: "drop" }, + }, + ], + }, + null, + 2 + )}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_ACTION_INVALID", + path: "rules/main.json#/rules/0/action/kind", + }) + ); expect(result.diagnostics).not.toContainEqual( expect.objectContaining({ code: "PLUGIN_RULE_PERMISSION_MISMATCH", From 39f58c99abac2d3e43aaab51c25e140d92832438 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 11:57:15 +0800 Subject: [PATCH 044/244] fix(plugin-devtools): validate strict rule runtime limits --- packages/create-aio-plugin/src/devtools.ts | 168 ++++++++++++++++- .../create-aio-plugin/src/scaffold.test.ts | 174 ++++++++++++++++++ 2 files changed, 341 insertions(+), 1 deletion(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 877456d6..25cd4bf6 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -383,6 +383,8 @@ const RULE_TARGET_FIELDS_BY_HOOK: Record = { "gateway.error": ["response.body"], "log.beforePersist": ["log.message"], }; +const MAX_RULES_PER_RUNTIME = 256; +const MAX_RULE_REGEX_PATTERN_BYTES = 4 * 1024; function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): PluginDiagnostic[] { const runtime = asRecord(manifest.runtime); @@ -439,6 +441,17 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): continue; } + if (document.rules.length > MAX_RULES_PER_RUNTIME) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_TOO_MANY_RULES", + message: `rule document has more than ${MAX_RULES_PER_RUNTIME} rules`, + path: `${rulePath}#/rules`, + hint: `Split this rule document or keep it at ${MAX_RULES_PER_RUNTIME} rules or fewer.`, + }); + continue; + } + document.rules.forEach((rawRule, index) => { const rule = asRecord(rawRule); if (!rule) { @@ -506,8 +519,10 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): code: "PLUGIN_RULE_MATCHER_INVALID", message: "rule match.regex must be a string", path: `${rulePath}#/rules/${index}/match/regex`, - hint: "Use a JavaScript-compatible regex string.", + hint: "Use a Rust regex-compatible string.", }); + } else { + diagnostics.push(...ruleMatcherDiagnostics(matcher.regex, rulePath, index)); } const action = asRecord(rule.action); if (!action) { @@ -529,6 +544,17 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): hint: "Use a hook-visible target field such as request.body.", }); } + if (target && typeof target.jsonPath === "string") { + diagnostics.push(...ruleJsonPathDiagnostics(target.jsonPath, rulePath, index)); + } else if (target && "jsonPath" in target) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_TARGET_INVALID", + message: "rule target.jsonPath must be a string", + path: `${rulePath}#/rules/${index}/target/jsonPath`, + hint: "Use the host JSONPath subset, for example $.messages[*].content.", + }); + } const actionKind = typeof action?.kind === "string" ? action.kind : ""; const actionDiagnostics = ruleActionDiagnostics(action, actionKind, rulePath, index); diagnostics.push(...actionDiagnostics); @@ -581,6 +607,122 @@ function permissionsForRuleTarget(field: string, actionKind: string): string[] { } } +function ruleMatcherDiagnostics( + regex: string, + rulePath: string, + index: number +): PluginDiagnostic[] { + const path = `${rulePath}#/rules/${index}/match/regex`; + if (new TextEncoder().encode(regex).length > MAX_RULE_REGEX_PATTERN_BYTES) { + return [ + { + severity: "error", + code: "PLUGIN_RULE_MATCHER_INVALID", + message: "rule match.regex is too large for the host runtime", + path, + hint: `Keep regex patterns at ${MAX_RULE_REGEX_PATTERN_BYTES} bytes or fewer.`, + }, + ]; + } + if (usesUnsupportedRustRegexSyntax(regex)) { + return [ + { + severity: "error", + code: "PLUGIN_RULE_MATCHER_INVALID", + message: "rule match.regex uses unsupported Rust regex syntax", + path, + hint: "Avoid look-around and backreferences; Plugin API v1 declarative rules use Rust regex syntax.", + }, + ]; + } + return []; +} + +function usesUnsupportedRustRegexSyntax(regex: string): boolean { + return /\\[1-9]/.test(regex) || /\(\?(?:[=!]|<[=!])/.test(regex); +} + +function ruleJsonPathDiagnostics( + jsonPath: string, + rulePath: string, + index: number +): PluginDiagnostic[] { + const path = `${rulePath}#/rules/${index}/target/jsonPath`; + if (!jsonPath.startsWith("$")) { + return [ + { + severity: "error", + code: "PLUGIN_RULE_TARGET_INVALID", + message: `JSON path must start with $: ${jsonPath}`, + path, + hint: "Use the host JSONPath subset, for example $.messages[*].content.", + }, + ]; + } + + let cursor = 1; + while (cursor < jsonPath.length) { + const char = jsonPath[cursor]; + if (char === ".") { + const start = cursor + 1; + cursor = start; + while (cursor < jsonPath.length && jsonPath[cursor] !== "." && jsonPath[cursor] !== "[") { + cursor += 1; + } + if (start === cursor) { + return [ + { + severity: "error", + code: "PLUGIN_RULE_TARGET_INVALID", + message: `empty JSON path segment: ${jsonPath}`, + path, + hint: "Use non-empty key segments such as $.messages[*].content.", + }, + ]; + } + const key = jsonPath.slice(start, cursor); + if (key.includes('"') || key.includes("'")) { + return [ + { + severity: "error", + code: "PLUGIN_RULE_TARGET_INVALID", + message: `quoted JSON path keys are not supported: ${jsonPath}`, + path, + hint: "Use dot-separated bare keys such as $.messages.", + }, + ]; + } + continue; + } + if (char === "[") { + if (jsonPath.slice(cursor, cursor + 3) !== "[*]") { + return [ + { + severity: "error", + code: "PLUGIN_RULE_TARGET_INVALID", + message: `only [*] array wildcards are supported: ${jsonPath}`, + path, + hint: "Use [*] instead of numeric indexes or filters.", + }, + ]; + } + cursor += 3; + continue; + } + return [ + { + severity: "error", + code: "PLUGIN_RULE_TARGET_INVALID", + message: `unsupported JSON path syntax: ${jsonPath}`, + path, + hint: "Use dot keys and [*] array wildcards only.", + }, + ]; + } + + return []; +} + function ruleActionDiagnostics( action: Record | null, actionKind: string, @@ -658,6 +800,30 @@ function ruleActionDiagnostics( }, ]; } + if (actionKind === "appendMessage") { + if (action.role !== "system" && action.role !== "developer") { + return [ + { + severity: "error", + code: "PLUGIN_RULE_ACTION_INVALID", + message: "appendMessage role must be system or developer", + path: `${path}/role`, + hint: "Set action.role to system or developer.", + }, + ]; + } + if (typeof action.content === "string" && action.content.trim().length === 0) { + return [ + { + severity: "error", + code: "PLUGIN_RULE_ACTION_INVALID", + message: "appendMessage content must not be empty", + path: `${path}/content`, + hint: "Set action.content to a non-empty message.", + }, + ]; + } + } return []; } diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index c82e907b..25481f2e 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -253,6 +253,34 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("validate strict rejects rule documents that exceed the host runtime limit", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + files["rules/main.json"] = `${JSON.stringify( + { + rules: Array.from({ length: 257 }, (_, index) => ({ + id: `rule-${index}`, + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "SECRET" }, + action: { kind: "replace", replacement: "[x]" }, + })), + }, + null, + 2 + )}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_RULE_TOO_MANY_RULES", + path: "rules/main.json#/rules", + }) + ); + }); + it("validate strict rejects rules whose hook is not declared by the manifest", () => { const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); const manifest = JSON.parse(files["plugin.json"] ?? "{}") as { @@ -431,6 +459,108 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("validate strict rejects regex patterns that the host runtime cannot compile", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + files["rules/main.json"] = `${JSON.stringify( + { + rules: [ + { + id: "lookahead", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "secret(?=token)" }, + action: { kind: "replace", replacement: "[x]" }, + }, + { + id: "oversized", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "a".repeat(4097) }, + action: { kind: "replace", replacement: "[x]" }, + }, + ], + }, + null, + 2 + )}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_INVALID", + path: "rules/main.json#/rules/0/match/regex", + message: expect.stringContaining("unsupported Rust regex syntax"), + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_INVALID", + path: "rules/main.json#/rules/1/match/regex", + message: expect.stringContaining("too large"), + }) + ); + }); + + it("validate strict rejects target jsonPath syntax that the host runtime cannot parse", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + files["rules/main.json"] = `${JSON.stringify( + { + rules: [ + { + id: "missing-root", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body", jsonPath: "messages[*].content" }, + match: { regex: "SECRET" }, + action: { kind: "replace", replacement: "[x]" }, + }, + { + id: "numeric-index", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body", jsonPath: "$.messages[0].content" }, + match: { regex: "SECRET" }, + action: { kind: "replace", replacement: "[x]" }, + }, + { + id: "quoted-key", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body", jsonPath: '$."messages"' }, + match: { regex: "SECRET" }, + action: { kind: "replace", replacement: "[x]" }, + }, + ], + }, + null, + 2 + )}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_TARGET_INVALID", + path: "rules/main.json#/rules/0/target/jsonPath", + message: expect.stringContaining("must start with $"), + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_TARGET_INVALID", + path: "rules/main.json#/rules/1/target/jsonPath", + message: expect.stringContaining("only [*] array wildcards"), + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_TARGET_INVALID", + path: "rules/main.json#/rules/2/target/jsonPath", + message: expect.stringContaining("quoted JSON path keys"), + }) + ); + }); + it("validate strict rejects unsupported action kinds", () => { const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); files["rules/main.json"] = `${JSON.stringify( @@ -459,6 +589,50 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("validate strict rejects appendMessage payloads that the host runtime rejects", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + files["rules/main.json"] = `${JSON.stringify( + { + rules: [ + { + id: "user-role", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "SECRET" }, + action: { kind: "appendMessage", role: "user", content: "hello" }, + }, + { + id: "blank-content", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "SECRET" }, + action: { kind: "appendMessage", role: "developer", content: " " }, + }, + ], + }, + null, + 2 + )}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_ACTION_INVALID", + path: "rules/main.json#/rules/0/action/role", + message: "appendMessage role must be system or developer", + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_ACTION_INVALID", + path: "rules/main.json#/rules/1/action/content", + message: "appendMessage content must not be empty", + }) + ); + }); + it("validate strict rejects write-only request rules because matching needs body read access", () => { const files = rulePluginFilesWithRule({ hook: "gateway.request.beforeSend", From fb7494ccffa0f47c44909e112af90de67de1604f Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 12:06:03 +0800 Subject: [PATCH 045/244] fix(plugin-devtools): validate merged strict rule shapes --- packages/create-aio-plugin/src/devtools.ts | 122 +++++++++++++++--- .../create-aio-plugin/src/scaffold.test.ts | 116 +++++++++++++++++ 2 files changed, 217 insertions(+), 21 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 25cd4bf6..2363f814 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -411,6 +411,8 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): ) : [] ); + const ruleDocuments: Array<{ rulePath: string; rules: unknown[] }> = []; + let totalRules = 0; for (const rulePath of runtime.rules) { if (!hasPluginFile(files, rulePath)) continue; @@ -441,6 +443,7 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): continue; } + totalRules += document.rules.length; if (document.rules.length > MAX_RULES_PER_RUNTIME) { diagnostics.push({ severity: "error", @@ -452,7 +455,21 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): continue; } - document.rules.forEach((rawRule, index) => { + ruleDocuments.push({ rulePath, rules: document.rules }); + } + + if (totalRules > MAX_RULES_PER_RUNTIME) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_TOO_MANY_RULES", + message: `merged rule documents have more than ${MAX_RULES_PER_RUNTIME} rules`, + path: "plugin.json#/runtime/rules", + hint: `Keep the combined declarative rule count at ${MAX_RULES_PER_RUNTIME} rules or fewer.`, + }); + } + + for (const { rulePath, rules } of ruleDocuments) { + rules.forEach((rawRule, index) => { const rule = asRecord(rawRule); if (!rule) { diagnostics.push({ @@ -522,8 +539,9 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): hint: "Use a Rust regex-compatible string.", }); } else { - diagnostics.push(...ruleMatcherDiagnostics(matcher.regex, rulePath, index)); + diagnostics.push(...ruleMatcherDiagnostics(matcher, matcher.regex, rulePath, index)); } + diagnostics.push(...ruleWhenDiagnostics(rule.when, rulePath, index)); const action = asRecord(rule.action); if (!action) { diagnostics.push({ @@ -608,34 +626,54 @@ function permissionsForRuleTarget(field: string, actionKind: string): string[] { } function ruleMatcherDiagnostics( + matcher: Record, regex: string, rulePath: string, index: number ): PluginDiagnostic[] { + const diagnostics: PluginDiagnostic[] = []; const path = `${rulePath}#/rules/${index}/match/regex`; + if ("caseSensitive" in matcher && typeof matcher.caseSensitive !== "boolean") { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_MATCHER_INVALID", + message: "rule match.caseSensitive must be a boolean", + path: `${rulePath}#/rules/${index}/match/caseSensitive`, + hint: "Set caseSensitive to true or false, or omit it.", + }); + } if (new TextEncoder().encode(regex).length > MAX_RULE_REGEX_PATTERN_BYTES) { - return [ - { - severity: "error", - code: "PLUGIN_RULE_MATCHER_INVALID", - message: "rule match.regex is too large for the host runtime", - path, - hint: `Keep regex patterns at ${MAX_RULE_REGEX_PATTERN_BYTES} bytes or fewer.`, - }, - ]; + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_MATCHER_INVALID", + message: "rule match.regex is too large for the host runtime", + path, + hint: `Keep regex patterns at ${MAX_RULE_REGEX_PATTERN_BYTES} bytes or fewer.`, + }); + return diagnostics; } if (usesUnsupportedRustRegexSyntax(regex)) { - return [ - { - severity: "error", - code: "PLUGIN_RULE_MATCHER_INVALID", - message: "rule match.regex uses unsupported Rust regex syntax", - path, - hint: "Avoid look-around and backreferences; Plugin API v1 declarative rules use Rust regex syntax.", - }, - ]; + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_MATCHER_INVALID", + message: "rule match.regex uses unsupported Rust regex syntax", + path, + hint: "Avoid look-around and backreferences; Plugin API v1 declarative rules use Rust regex syntax.", + }); + return diagnostics; } - return []; + try { + new RegExp(regex); + } catch (error) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_MATCHER_INVALID", + message: `invalid regex for host runtime: ${errorMessage(error)}`, + path, + hint: "Fix the regex pattern before packing the plugin.", + }); + } + return diagnostics; } function usesUnsupportedRustRegexSyntax(regex: string): boolean { @@ -723,6 +761,48 @@ function ruleJsonPathDiagnostics( return []; } +function ruleWhenDiagnostics(when: unknown, rulePath: string, index: number): PluginDiagnostic[] { + if (when == null) return []; + const path = `${rulePath}#/rules/${index}/when`; + const record = asRecord(when); + if (!record) { + return [ + { + severity: "error", + code: "PLUGIN_RULE_WHEN_INVALID", + message: "rule when must be an object", + path, + hint: "Use when.cliKeys, when.models, or when.configEquals.", + }, + ]; + } + + const diagnostics: PluginDiagnostic[] = []; + for (const field of ["cliKeys", "models"] as const) { + const value = record[field]; + if (value == null) continue; + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_WHEN_INVALID", + message: `rule when.${field} must be an array of strings`, + path: `${path}/${field}`, + hint: `Use ${field}: ["value"].`, + }); + } + } + if (record.configEquals != null && !asRecord(record.configEquals)) { + diagnostics.push({ + severity: "error", + code: "PLUGIN_RULE_WHEN_INVALID", + message: "rule when.configEquals must be an object", + path: `${path}/configEquals`, + hint: "Use key-value pairs that should match plugin config.", + }); + } + return diagnostics; +} + function ruleActionDiagnostics( action: Record | null, actionKind: string, diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index 25481f2e..b6505e87 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -281,6 +281,37 @@ describe("create-aio-plugin scaffold", () => { ); }); + it("validate strict rejects merged rule files that exceed the host runtime limit", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as { + runtime: { kind: "declarativeRules"; rules: string[] }; + }; + manifest.runtime.rules = ["rules/a.json", "rules/b.json"]; + files["plugin.json"] = `${JSON.stringify(manifest, null, 2)}\n`; + const rules = (prefix: string) => + Array.from({ length: 200 }, (_, index) => ({ + id: `${prefix}-${index}`, + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "SECRET" }, + action: { kind: "replace", replacement: "[x]" }, + })); + files["rules/a.json"] = `${JSON.stringify({ rules: rules("a") }, null, 2)}\n`; + files["rules/b.json"] = `${JSON.stringify({ rules: rules("b") }, null, 2)}\n`; + delete files["rules/main.json"]; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "PLUGIN_RULE_TOO_MANY_RULES", + path: "plugin.json#/runtime/rules", + }) + ); + }); + it("validate strict rejects rules whose hook is not declared by the manifest", () => { const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); const manifest = JSON.parse(files["plugin.json"] ?? "{}") as { @@ -478,6 +509,13 @@ describe("create-aio-plugin scaffold", () => { match: { regex: "a".repeat(4097) }, action: { kind: "replace", replacement: "[x]" }, }, + { + id: "invalid-syntax", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "[" }, + action: { kind: "replace", replacement: "[x]" }, + }, ], }, null, @@ -501,6 +539,84 @@ describe("create-aio-plugin scaffold", () => { message: expect.stringContaining("too large"), }) ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_INVALID", + path: "rules/main.json#/rules/2/match/regex", + message: expect.stringContaining("invalid regex"), + }) + ); + }); + + it("validate strict rejects matcher and when fields that host serde cannot parse", () => { + const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); + files["rules/main.json"] = `${JSON.stringify( + { + rules: [ + { + id: "bad-case-sensitive", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "SECRET", caseSensitive: "false" }, + action: { kind: "replace", replacement: "[x]" }, + }, + { + id: "bad-when-cli-keys", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "SECRET" }, + action: { kind: "replace", replacement: "[x]" }, + when: { cliKeys: "codex" }, + }, + { + id: "bad-when-models", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "SECRET" }, + action: { kind: "replace", replacement: "[x]" }, + when: { models: [7] }, + }, + { + id: "bad-when-config", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "SECRET" }, + action: { kind: "replace", replacement: "[x]" }, + when: { configEquals: [] }, + }, + ], + }, + null, + 2 + )}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_INVALID", + path: "rules/main.json#/rules/0/match/caseSensitive", + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_WHEN_INVALID", + path: "rules/main.json#/rules/1/when/cliKeys", + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_WHEN_INVALID", + path: "rules/main.json#/rules/2/when/models", + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_WHEN_INVALID", + path: "rules/main.json#/rules/3/when/configEquals", + }) + ); }); it("validate strict rejects target jsonPath syntax that the host runtime cannot parse", () => { From 6f5dc4ec059ceb575087e6be31a983b57d248dd5 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 12:14:23 +0800 Subject: [PATCH 046/244] fix(plugin-devtools): avoid js regex strict false positives --- packages/create-aio-plugin/src/devtools.ts | 17 ++++---------- .../create-aio-plugin/src/scaffold.test.ts | 23 +++++++++++++++++-- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 2363f814..02b18f4d 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -662,22 +662,15 @@ function ruleMatcherDiagnostics( }); return diagnostics; } - try { - new RegExp(regex); - } catch (error) { - diagnostics.push({ - severity: "error", - code: "PLUGIN_RULE_MATCHER_INVALID", - message: `invalid regex for host runtime: ${errorMessage(error)}`, - path, - hint: "Fix the regex pattern before packing the plugin.", - }); - } return diagnostics; } function usesUnsupportedRustRegexSyntax(regex: string): boolean { - return /\\[1-9]/.test(regex) || /\(\?(?:[=!]|<[=!])/.test(regex); + return ( + /\\[1-9]/.test(regex) || + /\\k(?:<[^>]+>|'[^']+')/.test(regex) || + /\(\?(?:[=!]|<[=!])/.test(regex) + ); } function ruleJsonPathDiagnostics( diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index b6505e87..2424f5b5 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -513,7 +513,7 @@ describe("create-aio-plugin scaffold", () => { id: "invalid-syntax", hook: "gateway.request.afterBodyRead", target: { field: "request.body" }, - match: { regex: "[" }, + match: { regex: "(?secret)\\k" }, action: { kind: "replace", replacement: "[x]" }, }, ], @@ -543,11 +543,30 @@ describe("create-aio-plugin scaffold", () => { expect.objectContaining({ code: "PLUGIN_RULE_MATCHER_INVALID", path: "rules/main.json#/rules/2/match/regex", - message: expect.stringContaining("invalid regex"), + message: expect.stringContaining("unsupported Rust regex syntax"), }) ); }); + it("validate strict accepts Rust regex inline flags that JavaScript RegExp cannot compile", () => { + const files = rulePluginFilesWithRule({ + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + }); + const document = JSON.parse(files["rules/main.json"] ?? "{}") as { + rules?: Array>; + }; + const rule = document.rules?.[0]; + if (rule) { + rule.match = { regex: "(?i)secret" }; + } + files["rules/main.json"] = `${JSON.stringify(document, null, 2)}\n`; + + const result = validatePluginFilesStrict(files); + + expect(result.ok).toBe(true); + }); + it("validate strict rejects matcher and when fields that host serde cannot parse", () => { const files = createPluginScaffold({ id: "acme.real", name: "Real", template: "rule" }); files["rules/main.json"] = `${JSON.stringify( From 7cf4e5b0af9c42660df3fc40c1f20bd6d30a4561 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 12:21:51 +0800 Subject: [PATCH 047/244] fix(plugin-devtools): catch malformed strict regex syntax --- packages/create-aio-plugin/src/devtools.ts | 63 ++++++++++++++++++- .../create-aio-plugin/src/scaffold.test.ts | 42 +++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 02b18f4d..e3aa2c17 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -652,7 +652,7 @@ function ruleMatcherDiagnostics( }); return diagnostics; } - if (usesUnsupportedRustRegexSyntax(regex)) { + if (usesUnsupportedRustRegexSyntax(regex) || hasMalformedRegexSyntax(regex)) { diagnostics.push({ severity: "error", code: "PLUGIN_RULE_MATCHER_INVALID", @@ -673,6 +673,67 @@ function usesUnsupportedRustRegexSyntax(regex: string): boolean { ); } +function hasMalformedRegexSyntax(regex: string): boolean { + let escaped = false; + let characterClassOpen = false; + let groupDepth = 0; + let hasPreviousAtom = false; + let previousChar = ""; + + for (const char of regex) { + if (escaped) { + escaped = false; + hasPreviousAtom = true; + previousChar = char; + continue; + } + if (char === "\\") { + escaped = true; + previousChar = char; + continue; + } + if (characterClassOpen) { + if (char === "]") { + characterClassOpen = false; + hasPreviousAtom = true; + } + previousChar = char; + continue; + } + if (char === "[") { + characterClassOpen = true; + previousChar = char; + continue; + } + if (char === "(") { + groupDepth += 1; + hasPreviousAtom = false; + previousChar = char; + continue; + } + if (char === ")") { + if (groupDepth === 0) return true; + groupDepth -= 1; + hasPreviousAtom = true; + previousChar = char; + continue; + } + if ( + (char === "*" || char === "+" || char === "?") && + !hasPreviousAtom && + previousChar !== "(" + ) { + return true; + } + if (char !== "^" && char !== "$" && char !== "|") { + hasPreviousAtom = true; + } + previousChar = char; + } + + return escaped || characterClassOpen || groupDepth > 0; +} + function ruleJsonPathDiagnostics( jsonPath: string, rulePath: string, diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index 2424f5b5..c52ca162 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -513,6 +513,27 @@ describe("create-aio-plugin scaffold", () => { id: "invalid-syntax", hook: "gateway.request.afterBodyRead", target: { field: "request.body" }, + match: { regex: "[" }, + action: { kind: "replace", replacement: "[x]" }, + }, + { + id: "invalid-group", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "(" }, + action: { kind: "replace", replacement: "[x]" }, + }, + { + id: "invalid-repeat", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "*" }, + action: { kind: "replace", replacement: "[x]" }, + }, + { + id: "named-backref", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, match: { regex: "(?secret)\\k" }, action: { kind: "replace", replacement: "[x]" }, }, @@ -546,6 +567,27 @@ describe("create-aio-plugin scaffold", () => { message: expect.stringContaining("unsupported Rust regex syntax"), }) ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_INVALID", + path: "rules/main.json#/rules/3/match/regex", + message: expect.stringContaining("unsupported Rust regex syntax"), + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_INVALID", + path: "rules/main.json#/rules/4/match/regex", + message: expect.stringContaining("unsupported Rust regex syntax"), + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_INVALID", + path: "rules/main.json#/rules/5/match/regex", + message: expect.stringContaining("unsupported Rust regex syntax"), + }) + ); }); it("validate strict accepts Rust regex inline flags that JavaScript RegExp cannot compile", () => { From 0ddc5f567b288b8305b284d8acfcfd6cd67138cc Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 12:28:06 +0800 Subject: [PATCH 048/244] fix(plugin-devtools): reject alternation-leading repeaters --- packages/create-aio-plugin/src/devtools.ts | 5 +++ .../create-aio-plugin/src/scaffold.test.ts | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index e3aa2c17..8898e683 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -725,6 +725,11 @@ function hasMalformedRegexSyntax(regex: string): boolean { ) { return true; } + if (char === "|") { + hasPreviousAtom = false; + previousChar = char; + continue; + } if (char !== "^" && char !== "$" && char !== "|") { hasPreviousAtom = true; } diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index c52ca162..b7a4b585 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -537,6 +537,27 @@ describe("create-aio-plugin scaffold", () => { match: { regex: "(?secret)\\k" }, action: { kind: "replace", replacement: "[x]" }, }, + { + id: "invalid-alternation-star", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "secret|*token" }, + action: { kind: "replace", replacement: "[x]" }, + }, + { + id: "invalid-alternation-plus", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "secret|+token" }, + action: { kind: "replace", replacement: "[x]" }, + }, + { + id: "invalid-alternation-question", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "secret|?token" }, + action: { kind: "replace", replacement: "[x]" }, + }, ], }, null, @@ -588,6 +609,27 @@ describe("create-aio-plugin scaffold", () => { message: expect.stringContaining("unsupported Rust regex syntax"), }) ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_INVALID", + path: "rules/main.json#/rules/6/match/regex", + message: expect.stringContaining("unsupported Rust regex syntax"), + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_INVALID", + path: "rules/main.json#/rules/7/match/regex", + message: expect.stringContaining("unsupported Rust regex syntax"), + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_INVALID", + path: "rules/main.json#/rules/8/match/regex", + message: expect.stringContaining("unsupported Rust regex syntax"), + }) + ); }); it("validate strict accepts Rust regex inline flags that JavaScript RegExp cannot compile", () => { From 43c0eb776161ae786947af3af116d9f686d207de Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 12:33:05 +0800 Subject: [PATCH 049/244] fix(plugin-devtools): reject group-leading repeaters --- packages/create-aio-plugin/src/devtools.ts | 2 +- .../create-aio-plugin/src/scaffold.test.ts | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 8898e683..fb6f9173 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -721,7 +721,7 @@ function hasMalformedRegexSyntax(regex: string): boolean { if ( (char === "*" || char === "+" || char === "?") && !hasPreviousAtom && - previousChar !== "(" + (char !== "?" || previousChar !== "(") ) { return true; } diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index b7a4b585..74911a2a 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -558,6 +558,20 @@ describe("create-aio-plugin scaffold", () => { match: { regex: "secret|?token" }, action: { kind: "replace", replacement: "[x]" }, }, + { + id: "invalid-group-star", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "(*token)" }, + action: { kind: "replace", replacement: "[x]" }, + }, + { + id: "invalid-group-plus", + hook: "gateway.request.afterBodyRead", + target: { field: "request.body" }, + match: { regex: "(+token)" }, + action: { kind: "replace", replacement: "[x]" }, + }, ], }, null, @@ -630,6 +644,20 @@ describe("create-aio-plugin scaffold", () => { message: expect.stringContaining("unsupported Rust regex syntax"), }) ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_INVALID", + path: "rules/main.json#/rules/9/match/regex", + message: expect.stringContaining("unsupported Rust regex syntax"), + }) + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "PLUGIN_RULE_MATCHER_INVALID", + path: "rules/main.json#/rules/10/match/regex", + message: expect.stringContaining("unsupported Rust regex syntax"), + }) + ); }); it("validate strict accepts Rust regex inline flags that JavaScript RegExp cannot compile", () => { From e768f790e7c9259aaf46407d9db99c13c87233c2 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 12:43:59 +0800 Subject: [PATCH 050/244] feat(plugin-devtools): explain declarative rule replay --- packages/create-aio-plugin/src/devtools.ts | 198 ++++++++++++++++-- .../create-aio-plugin/src/scaffold.test.ts | 120 +++++++++++ 2 files changed, 304 insertions(+), 14 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index fb6f9173..7bb026ec 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -54,6 +54,35 @@ export type StrictValidationResult = | { ok: true; diagnostics: PluginDiagnostic[] } | { ok: false; error: { code: string; message: string }; diagnostics: PluginDiagnostic[] }; +export type ReplayMutationSummary = + | { changed: false } + | { + changed: true; + field: "requestBody" | "responseBody" | "streamChunk" | "logMessage"; + targetField: string; + jsonPath?: string; + }; + +export type ReplayExplainResult = { + pluginId: string; + runtime: PluginManifest["runtime"]["kind"]; + hook: GatewayHookName; + evaluatedRuleCount: number; + matchedRuleIds: string[]; + actionKind: ReplayRuleResult["action"]; + outputKind: ReplayRuleResult["action"]; + mutationSummary: ReplayMutationSummary; + warnings: PluginDiagnostic[]; + result: ReplayRuleResult; +}; + +export type ReplayRuleTrace = { + ruleId?: string; + result: ReplayRuleResult; + targetField?: string; + jsonPath?: string; +}; + type DoctorOptions = { strict?: boolean; }; @@ -115,14 +144,24 @@ export function runCreateAioPluginCli(args: string[], cwd: string, io: CliIo = c } if (commandOrId === "replay") { - if (!firstArg || !secondArg || !thirdArg) { + 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 "); 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)); + const fixture = JSON.parse(readFileSync(resolve(cwd, fixturePath), "utf8")) as unknown; + io.log( + JSON.stringify( + explain + ? replayHookExplain(files, hookName as GatewayHookName, fixture) + : replayHook(files, hookName as GatewayHookName, fixture) + ) + ); return 0; } catch (error) { io.error(`failed to replay plugin hook: ${errorMessage(error)}`); @@ -1223,6 +1262,72 @@ export function replayHook(files: ScaffoldFiles, hook: GatewayHookName, context: return { action: "pass" }; } +export function replayHookExplain( + files: ScaffoldFiles, + hook: GatewayHookName, + context: unknown +): ReplayExplainResult { + const validation = validatePluginFilesStrict(files); + if (!validation.ok) { + throw new Error(`${validation.error.code}: ${validation.error.message}`); + } + const manifest = JSON.parse(files["plugin.json"] ?? "{}") as PluginManifest; + const warnings: PluginDiagnostic[] = []; + const passResult: ReplayRuleResult = { action: "pass" }; + + if (manifest.runtime.kind !== "declarativeRules") { + warnings.push({ + severity: "warn", + code: "PLUGIN_REPLAY_UNSUPPORTED_RUNTIME", + message: "replay explain is only supported for declarative rule plugins", + }); + return { + pluginId: manifest.id, + runtime: manifest.runtime.kind, + hook, + evaluatedRuleCount: 0, + matchedRuleIds: [], + actionKind: passResult.action, + outputKind: passResult.action, + mutationSummary: { changed: false }, + warnings, + result: passResult, + }; + } + + let evaluatedRuleCount = 0; + const matchedRuleIds: string[] = []; + let result: ReplayRuleResult = passResult; + let mutationSummary: ReplayMutationSummary = { changed: false }; + + for (const rulePath of manifest.runtime.rules) { + const document = JSON.parse(files[rulePath] ?? '{"rules":[]}') as { rules?: unknown[] }; + for (const rule of document.rules ?? []) { + evaluatedRuleCount += 1; + const trace = replayDeclarativeRuleWithTrace(rule, hook, context); + if (trace.result.action === "pass") continue; + if (trace.ruleId) matchedRuleIds.push(trace.ruleId); + result = trace.result; + mutationSummary = mutationSummaryFromReplayResult(trace); + break; + } + if (result.action !== "pass") break; + } + + return { + pluginId: manifest.id, + runtime: manifest.runtime.kind, + hook, + evaluatedRuleCount, + matchedRuleIds, + actionKind: result.action, + outputKind: result.action, + mutationSummary, + warnings, + result, + }; +} + export function packPlugin(files: ScaffoldFiles): PackedPlugin { const bytes = createStoredZipBytes(textFilesToBytes(files)); return { @@ -1320,18 +1425,28 @@ function replayDeclarativeRule( hook: GatewayHookName, context: unknown ): ReplayRuleResult { + return replayDeclarativeRuleWithTrace(rawRule, hook, context).result; +} + +function replayDeclarativeRuleWithTrace( + rawRule: unknown, + hook: GatewayHookName, + context: unknown +): ReplayRuleTrace { const rule = asRecord(rawRule); - if (rule?.hook !== hook) return { action: "pass" }; + const ruleId = typeof rule?.id === "string" ? rule.id : undefined; + const pass = (): ReplayRuleTrace => ({ ruleId, result: { action: "pass" } }); + if (rule?.hook !== hook) return 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" }; + if (!target || !matcher || !action) return pass(); const regex = compileReplayRegex(matcher); - if (!regex) return { action: "pass" }; + if (!regex) return pass(); const targetField = typeof target.field === "string" ? target.field : "request.body"; const text = textFromFixture(context, targetField); - if (!text) return { action: "pass" }; + if (!text) return pass(); const path = typeof target.jsonPath === "string" @@ -1343,9 +1458,18 @@ function replayDeclarativeRule( !path || (target.field && target.field !== "request.body" && target.field !== "response.body") ) { - return replayTextAction(text, regex, action, targetField); + return { + ruleId, + result: replayTextAction(text, regex, action, targetField), + targetField, + }; } - return replayJsonPathAction(text, path, regex, action, targetField); + return { + ruleId, + result: replayJsonPathAction(text, path, regex, action, targetField), + targetField, + jsonPath: path, + }; } type ReplayRuleResult = @@ -1391,14 +1515,19 @@ function textFromFixture(context: unknown, field: string): string | undefined { return undefined; } +function replayRegexMatches(regex: RegExp, value: string): boolean { + const matched = regex.test(value); + regex.lastIndex = 0; + return matched; +} + function replayTextAction( body: string, regex: RegExp, action: Record, field: string ): ReplayRuleResult { - if (!regex.test(body)) return { action: "pass" }; - regex.lastIndex = 0; + if (!replayRegexMatches(regex, body)) return { action: "pass" }; if (action.kind === "replace" && typeof action.replacement === "string") { return replaceResult(field, body.replace(regex, action.replacement)); } @@ -1437,8 +1566,7 @@ function replayJsonPathAction( let matched = false; let changed = false; applyToJsonStrings(root, segments, (candidate) => { - if (!regex.test(candidate.value)) return; - regex.lastIndex = 0; + if (!replayRegexMatches(regex, candidate.value)) return; matched = true; if (action.kind === "replace" && typeof action.replacement === "string") { const next = candidate.value.replace(regex, action.replacement); @@ -1469,6 +1597,48 @@ function replayJsonPathAction( return { action: "pass" }; } +function mutationSummaryFromReplayResult(trace: ReplayRuleTrace): ReplayMutationSummary { + if (trace.result.action !== "replace") return { changed: false }; + return summaryForReplacement(trace.result, trace.targetField, trace.jsonPath); +} + +function summaryForReplacement( + result: Extract, + targetField: string | undefined, + jsonPath: string | undefined +): ReplayMutationSummary { + if ("requestBody" in result) { + return { + changed: true, + field: "requestBody", + targetField: targetField ?? "request.body", + ...(jsonPath ? { jsonPath } : {}), + }; + } + if ("responseBody" in result) { + return { + changed: true, + field: "responseBody", + targetField: targetField ?? "response.body", + ...(jsonPath ? { jsonPath } : {}), + }; + } + if ("streamChunk" in result) { + return { + changed: true, + field: "streamChunk", + targetField: targetField ?? "stream.chunk", + ...(jsonPath ? { jsonPath } : {}), + }; + } + return { + changed: true, + field: "logMessage", + targetField: targetField ?? "log.message", + ...(jsonPath ? { jsonPath } : {}), + }; +} + function replaceResult(field: string, value: string): ReplayRuleResult { switch (field) { case "response.body": diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index 74911a2a..4f53cda1 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -13,6 +13,7 @@ import { packPluginBytes, packPluginDirectory, replayHook, + replayHookExplain, runCreateAioPluginCli, signPackage, validatePluginDirectory, @@ -1574,6 +1575,124 @@ describe("create-aio-plugin scaffold", () => { ) ).toEqual({ action: "replace", logMessage: "apiKey=[REDACTED]" }); }); + + it("replay explain reports a pass when no rule matches", () => { + const result = replayHookExplain( + rulePluginFilesWithTarget(undefined), + "gateway.request.afterBodyRead", + { request: { body: "ordinary text" } } + ); + + expect(result).toMatchObject({ + pluginId: "acme.redactor", + runtime: "declarativeRules", + hook: "gateway.request.afterBodyRead", + evaluatedRuleCount: 1, + matchedRuleIds: [], + actionKind: "pass", + outputKind: "pass", + mutationSummary: { changed: false }, + result: { action: "pass" }, + }); + }); + + it("replay explain reports replacement mutation details", () => { + const result = replayHookExplain( + rulePluginFilesWithTarget("$.messages[*].content"), + "gateway.request.afterBodyRead", + { + request: { + body: JSON.stringify({ + messages: [{ role: "user", content: "SECRET_TOKEN" }], + }), + }, + } + ); + + expect(result).toMatchObject({ + matchedRuleIds: ["redact-token-rule"], + actionKind: "replace", + outputKind: "replace", + mutationSummary: { + changed: true, + field: "requestBody", + targetField: "request.body", + jsonPath: "$.messages[*].content", + }, + }); + expect(JSON.stringify(result)).toContain("[REDACTED]"); + }); + + it("replay explain reports block and warn matches without mutations", () => { + const blockResult = replayHookExplain( + rulePluginFilesWithAction({ kind: "block", reason: "blocked" }), + "gateway.request.afterBodyRead", + { request: { body: "danger" } } + ); + + expect(blockResult).toMatchObject({ + matchedRuleIds: ["redact-token-rule"], + actionKind: "block", + outputKind: "block", + mutationSummary: { changed: false }, + result: { action: "block", reason: "blocked" }, + }); + + const warnResult = replayHookExplain( + rulePluginFilesWithAction({ kind: "warn", message: "careful" }), + "gateway.request.afterBodyRead", + { request: { body: "danger" } } + ); + + expect(warnResult).toMatchObject({ + matchedRuleIds: ["redact-token-rule"], + actionKind: "warn", + outputKind: "warn", + mutationSummary: { changed: false }, + result: { action: "warn", message: "careful" }, + }); + }); + + it("replay explain command emits JSON explanation", () => { + const root = mkdtempSync(join(tmpdir(), "aio-plugin-replay-explain-")); + const fixturePath = join(root, "fixture.json"); + writeScaffold(root, rulePluginFilesWithTarget("$.messages[*].content")); + writeFileSync( + fixturePath, + JSON.stringify({ + request: { + body: JSON.stringify({ + messages: [{ role: "user", content: "SECRET_TOKEN" }], + }), + }, + }) + ); + const output: string[] = []; + + expect( + runCreateAioPluginCli( + ["replay", "--explain", root, fixturePath, "gateway.request.afterBodyRead"], + process.cwd(), + { + log: (line) => output.push(line), + error: (line) => output.push(line), + } + ) + ).toBe(0); + + expect(JSON.parse(output[0] ?? "{}")).toMatchObject({ + pluginId: "acme.redactor", + matchedRuleIds: ["redact-token-rule"], + actionKind: "replace", + outputKind: "replace", + mutationSummary: { + changed: true, + field: "requestBody", + targetField: "request.body", + jsonPath: "$.messages[*].content", + }, + }); + }); }); function writeScaffold(root: string, files: Record): void { @@ -1640,6 +1759,7 @@ function rulePluginFilesWithRule(options: { }; const rule = document.rules?.[0]; if (rule) { + rule.id = "redact-token-rule"; rule.hook = options.hook; rule.target = options.target; } From 213296e6c86e1d63fb5cdc269b51d13533d534a7 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 12:55:29 +0800 Subject: [PATCH 051/244] fix(plugin-devtools): explain rust inline regex flags --- packages/create-aio-plugin/src/devtools.ts | 27 ++++++++++++++++++- .../create-aio-plugin/src/scaffold.test.ts | 23 ++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 7bb026ec..b0ed49f9 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -1485,13 +1485,38 @@ type JsonPathSegment = { kind: "key"; key: string } | { kind: "wildcardArray" }; function compileReplayRegex(matcher: Record): RegExp | null { if (typeof matcher.regex !== "string") return null; + const parsed = parseReplayRegexPattern(matcher.regex); try { - return new RegExp(matcher.regex, matcher.caseSensitive === false ? "gi" : "g"); + return new RegExp(parsed.pattern, replayRegexFlags(matcher, parsed.flags)); } catch { return null; } } +function parseReplayRegexPattern(regex: string): { pattern: string; flags: Set } { + const flags = new Set(); + const match = regex.match(/^\(\?([imsux-]+)\)/); + if (!match) return { pattern: regex, flags }; + let enabled = true; + for (const flag of match[1] ?? "") { + if (flag === "-") { + enabled = false; + continue; + } + if (enabled) flags.add(flag); + } + return { pattern: regex.slice(match[0].length), flags }; +} + +function replayRegexFlags(matcher: Record, inlineFlags: Set): string { + const flags = new Set(["g"]); + if (matcher.caseSensitive === false || inlineFlags.has("i")) flags.add("i"); + if (inlineFlags.has("m")) flags.add("m"); + if (inlineFlags.has("s")) flags.add("s"); + if (inlineFlags.has("u")) flags.add("u"); + return Array.from(flags).join(""); +} + function textFromFixture(context: unknown, field: string): string | undefined { if (typeof context === "string") return context; const contextRecord = asRecord(context); diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index 4f53cda1..c78abc6d 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -1623,6 +1623,29 @@ describe("create-aio-plugin scaffold", () => { expect(JSON.stringify(result)).toContain("[REDACTED]"); }); + it("replay explain supports Rust regex inline flags accepted by strict validation", () => { + const files = rulePluginFilesWithTarget(undefined); + const document = JSON.parse(files["rules/main.json"] ?? "{}") as { + rules?: Array>; + }; + const rule = document.rules?.[0]; + if (rule) { + rule.match = { regex: "(?i)secret" }; + } + files["rules/main.json"] = `${JSON.stringify(document, null, 2)}\n`; + + const result = replayHookExplain(files, "gateway.request.afterBodyRead", { + request: { body: "SECRET token" }, + }); + + expect(result).toMatchObject({ + matchedRuleIds: ["redact-token-rule"], + actionKind: "replace", + outputKind: "replace", + result: { action: "replace", requestBody: "[REDACTED] token" }, + }); + }); + it("replay explain reports block and warn matches without mutations", () => { const blockResult = replayHookExplain( rulePluginFilesWithAction({ kind: "block", reason: "blocked" }), From 5ee20eb3a19dbca26157ae95358abf1c917f335f Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 13:01:07 +0800 Subject: [PATCH 052/244] fix(plugin-devtools): explain extended regex flags --- packages/create-aio-plugin/src/devtools.ts | 76 ++++++++++++++++--- .../create-aio-plugin/src/scaffold.test.ts | 42 ++++++++++ 2 files changed, 107 insertions(+), 11 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index b0ed49f9..de586d61 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -1487,36 +1487,90 @@ function compileReplayRegex(matcher: Record): RegExp | null { if (typeof matcher.regex !== "string") return null; const parsed = parseReplayRegexPattern(matcher.regex); try { - return new RegExp(parsed.pattern, replayRegexFlags(matcher, parsed.flags)); + return new RegExp(parsed.pattern, replayRegexFlags(matcher, parsed)); } catch { return null; } } -function parseReplayRegexPattern(regex: string): { pattern: string; flags: Set } { - const flags = new Set(); +type ReplayRegexPattern = { + pattern: string; + enabledFlags: Set; + disabledFlags: Set; +}; + +function parseReplayRegexPattern(regex: string): ReplayRegexPattern { + const enabledFlags = new Set(); + const disabledFlags = new Set(); const match = regex.match(/^\(\?([imsux-]+)\)/); - if (!match) return { pattern: regex, flags }; + if (!match) return { pattern: regex, enabledFlags, disabledFlags }; let enabled = true; for (const flag of match[1] ?? "") { if (flag === "-") { enabled = false; continue; } - if (enabled) flags.add(flag); + if (enabled) { + enabledFlags.add(flag); + disabledFlags.delete(flag); + } else { + disabledFlags.add(flag); + enabledFlags.delete(flag); + } } - return { pattern: regex.slice(match[0].length), flags }; + const pattern = regex.slice(match[0].length); + return { + pattern: enabledFlags.has("x") ? stripReplayRegexExtendedWhitespace(pattern) : pattern, + enabledFlags, + disabledFlags, + }; } -function replayRegexFlags(matcher: Record, inlineFlags: Set): string { +function replayRegexFlags(matcher: Record, parsed: ReplayRegexPattern): string { const flags = new Set(["g"]); - if (matcher.caseSensitive === false || inlineFlags.has("i")) flags.add("i"); - if (inlineFlags.has("m")) flags.add("m"); - if (inlineFlags.has("s")) flags.add("s"); - if (inlineFlags.has("u")) flags.add("u"); + if ( + parsed.enabledFlags.has("i") || + (matcher.caseSensitive === false && !parsed.disabledFlags.has("i")) + ) { + flags.add("i"); + } + if (parsed.enabledFlags.has("m")) flags.add("m"); + if (parsed.enabledFlags.has("s")) flags.add("s"); + if (parsed.enabledFlags.has("u")) flags.add("u"); return Array.from(flags).join(""); } +function stripReplayRegexExtendedWhitespace(pattern: string): string { + let stripped = ""; + let escaped = false; + let characterClassOpen = false; + for (const char of pattern) { + if (escaped) { + stripped += char; + escaped = false; + continue; + } + if (char === "\\") { + stripped += char; + escaped = true; + continue; + } + if (characterClassOpen) { + stripped += char; + if (char === "]") characterClassOpen = false; + continue; + } + if (char === "[") { + stripped += char; + characterClassOpen = true; + continue; + } + if (/\s/.test(char)) continue; + stripped += char; + } + return stripped; +} + function textFromFixture(context: unknown, field: string): string | undefined { if (typeof context === "string") return context; const contextRecord = asRecord(context); diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index c78abc6d..f9a4951d 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -1646,6 +1646,48 @@ describe("create-aio-plugin scaffold", () => { }); }); + it("replay explain handles Rust extended and disabled inline flags", () => { + const extendedFiles = rulePluginFilesWithTarget(undefined); + const extendedDocument = JSON.parse(extendedFiles["rules/main.json"] ?? "{}") as { + rules?: Array>; + }; + const extendedRule = extendedDocument.rules?.[0]; + if (extendedRule) { + extendedRule.match = { regex: "(?x)sec ret" }; + } + extendedFiles["rules/main.json"] = `${JSON.stringify(extendedDocument, null, 2)}\n`; + + expect( + replayHookExplain(extendedFiles, "gateway.request.afterBodyRead", { + request: { body: "secret token" }, + }) + ).toMatchObject({ + matchedRuleIds: ["redact-token-rule"], + outputKind: "replace", + result: { action: "replace", requestBody: "[REDACTED] token" }, + }); + + const disabledFiles = rulePluginFilesWithTarget(undefined); + const disabledDocument = JSON.parse(disabledFiles["rules/main.json"] ?? "{}") as { + rules?: Array>; + }; + const disabledRule = disabledDocument.rules?.[0]; + if (disabledRule) { + disabledRule.match = { regex: "(?-i)secret", caseSensitive: false }; + } + disabledFiles["rules/main.json"] = `${JSON.stringify(disabledDocument, null, 2)}\n`; + + expect( + replayHookExplain(disabledFiles, "gateway.request.afterBodyRead", { + request: { body: "SECRET token" }, + }) + ).toMatchObject({ + matchedRuleIds: [], + outputKind: "pass", + result: { action: "pass" }, + }); + }); + it("replay explain reports block and warn matches without mutations", () => { const blockResult = replayHookExplain( rulePluginFilesWithAction({ kind: "block", reason: "blocked" }), From 7283908e5882f6f3f0c2a45b90239a106c6d128b Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 13:09:47 +0800 Subject: [PATCH 053/244] fix(plugin-devtools): warn on unsupported replay regex flags --- packages/create-aio-plugin/src/devtools.ts | 61 ++++++++++++-- .../create-aio-plugin/src/scaffold.test.ts | 79 ++++++++++++++----- 2 files changed, 111 insertions(+), 29 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index de586d61..d2842fad 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -81,6 +81,7 @@ export type ReplayRuleTrace = { result: ReplayRuleResult; targetField?: string; jsonPath?: string; + warning?: PluginDiagnostic; }; type DoctorOptions = { @@ -1305,6 +1306,7 @@ export function replayHookExplain( for (const rule of document.rules ?? []) { evaluatedRuleCount += 1; const trace = replayDeclarativeRuleWithTrace(rule, hook, context); + if (trace.warning) warnings.push(trace.warning); if (trace.result.action === "pass") continue; if (trace.ruleId) matchedRuleIds.push(trace.ruleId); result = trace.result; @@ -1425,7 +1427,9 @@ function replayDeclarativeRule( hook: GatewayHookName, context: unknown ): ReplayRuleResult { - return replayDeclarativeRuleWithTrace(rawRule, hook, context).result; + const trace = replayDeclarativeRuleWithTrace(rawRule, hook, context); + if (trace.warning) throw new Error(`${trace.warning.code}: ${trace.warning.message}`); + return trace.result; } function replayDeclarativeRuleWithTrace( @@ -1441,8 +1445,9 @@ function replayDeclarativeRuleWithTrace( const matcher = asRecord(rule.matcher) ?? asRecord(rule.match); const action = asRecord(rule.action); if (!target || !matcher || !action) return pass(); - const regex = compileReplayRegex(matcher); - if (!regex) return pass(); + const compiled = compileReplayRegex(matcher); + if (!compiled.ok) return { ...pass(), warning: compiled.warning }; + const regex = compiled.regex; const targetField = typeof target.field === "string" ? target.field : "request.body"; const text = textFromFixture(context, targetField); @@ -1483,13 +1488,21 @@ type ReplayRuleResult = type JsonPathSegment = { kind: "key"; key: string } | { kind: "wildcardArray" }; -function compileReplayRegex(matcher: Record): RegExp | null { - if (typeof matcher.regex !== "string") return null; +type ReplayRegexCompileResult = + | { ok: true; regex: RegExp } + | { ok: false; warning?: PluginDiagnostic }; + +function compileReplayRegex(matcher: Record): ReplayRegexCompileResult { + if (typeof matcher.regex !== "string") return { ok: false }; const parsed = parseReplayRegexPattern(matcher.regex); + if (parsed.warning) return { ok: false, warning: parsed.warning }; try { - return new RegExp(parsed.pattern, replayRegexFlags(matcher, parsed)); + return { ok: true, regex: new RegExp(parsed.pattern, replayRegexFlags(matcher, parsed)) }; } catch { - return null; + return { + ok: false, + warning: replayRegexUnsupportedDiagnostic("rule regex cannot be replayed by JavaScript"), + }; } } @@ -1497,13 +1510,21 @@ type ReplayRegexPattern = { pattern: string; enabledFlags: Set; disabledFlags: Set; + warning?: PluginDiagnostic; }; function parseReplayRegexPattern(regex: string): ReplayRegexPattern { const enabledFlags = new Set(); const disabledFlags = new Set(); const match = regex.match(/^\(\?([imsux-]+)\)/); - if (!match) return { pattern: regex, enabledFlags, disabledFlags }; + if (!match) { + return { + pattern: regex, + enabledFlags, + disabledFlags, + warning: hasInlineFlagToggle(regex) ? replayRegexUnsupportedDiagnostic() : undefined, + }; + } let enabled = true; for (const flag of match[1] ?? "") { if (flag === "-") { @@ -1519,10 +1540,15 @@ function parseReplayRegexPattern(regex: string): ReplayRegexPattern { } } const pattern = regex.slice(match[0].length); + const warning = + hasInlineFlagToggle(pattern) || hasUnsupportedExtendedReplayPattern(pattern, enabledFlags) + ? replayRegexUnsupportedDiagnostic() + : undefined; return { pattern: enabledFlags.has("x") ? stripReplayRegexExtendedWhitespace(pattern) : pattern, enabledFlags, disabledFlags, + ...(warning ? { warning } : {}), }; } @@ -1540,6 +1566,25 @@ function replayRegexFlags(matcher: Record, parsed: ReplayRegexP return Array.from(flags).join(""); } +function hasInlineFlagToggle(pattern: string): boolean { + return /\(\?[imsux-]+[:)]/.test(pattern); +} + +function hasUnsupportedExtendedReplayPattern(pattern: string, enabledFlags: Set): boolean { + return enabledFlags.has("x") && (pattern.includes("#") || /\[[^\]]*\s[^\]]*\]/.test(pattern)); +} + +function replayRegexUnsupportedDiagnostic( + message = "rule regex uses Rust regex syntax that replay cannot safely emulate" +): PluginDiagnostic { + return { + severity: "warn", + code: "PLUGIN_REPLAY_REGEX_UNSUPPORTED", + message, + hint: "Use host runtime logs for exact Rust regex behavior.", + }; +} + function stripReplayRegexExtendedWhitespace(pattern: string): string { let stripped = ""; let escaped = false; diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index f9a4951d..eba2052e 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -1646,27 +1646,7 @@ describe("create-aio-plugin scaffold", () => { }); }); - it("replay explain handles Rust extended and disabled inline flags", () => { - const extendedFiles = rulePluginFilesWithTarget(undefined); - const extendedDocument = JSON.parse(extendedFiles["rules/main.json"] ?? "{}") as { - rules?: Array>; - }; - const extendedRule = extendedDocument.rules?.[0]; - if (extendedRule) { - extendedRule.match = { regex: "(?x)sec ret" }; - } - extendedFiles["rules/main.json"] = `${JSON.stringify(extendedDocument, null, 2)}\n`; - - expect( - replayHookExplain(extendedFiles, "gateway.request.afterBodyRead", { - request: { body: "secret token" }, - }) - ).toMatchObject({ - matchedRuleIds: ["redact-token-rule"], - outputKind: "replace", - result: { action: "replace", requestBody: "[REDACTED] token" }, - }); - + it("replay explain handles disabled leading inline flags", () => { const disabledFiles = rulePluginFilesWithTarget(undefined); const disabledDocument = JSON.parse(disabledFiles["rules/main.json"] ?? "{}") as { rules?: Array>; @@ -1688,6 +1668,63 @@ describe("create-aio-plugin scaffold", () => { }); }); + it("replay explain warns instead of false passing complex Rust regex flags", () => { + const toggledFiles = rulePluginFilesWithTarget(undefined); + const toggledDocument = JSON.parse(toggledFiles["rules/main.json"] ?? "{}") as { + rules?: Array>; + }; + const toggledRule = toggledDocument.rules?.[0]; + if (toggledRule) { + toggledRule.match = { regex: "(?i)sec(?-i)ret" }; + } + toggledFiles["rules/main.json"] = `${JSON.stringify(toggledDocument, null, 2)}\n`; + + const toggledResult = replayHookExplain(toggledFiles, "gateway.request.afterBodyRead", { + request: { body: "SECRET token" }, + }); + + expect(toggledResult).toMatchObject({ + matchedRuleIds: [], + outputKind: "pass", + warnings: [ + expect.objectContaining({ + severity: "warn", + code: "PLUGIN_REPLAY_REGEX_UNSUPPORTED", + }), + ], + }); + expect(() => + replayHook(toggledFiles, "gateway.request.afterBodyRead", { + request: { body: "SECRET token" }, + }) + ).toThrow(/PLUGIN_REPLAY_REGEX_UNSUPPORTED/); + + const extendedFiles = rulePluginFilesWithTarget(undefined); + const extendedDocument = JSON.parse(extendedFiles["rules/main.json"] ?? "{}") as { + rules?: Array>; + }; + const extendedRule = extendedDocument.rules?.[0]; + if (extendedRule) { + extendedRule.match = { regex: "(?x)[ a ]" }; + } + extendedFiles["rules/main.json"] = `${JSON.stringify(extendedDocument, null, 2)}\n`; + + expect( + replayHookExplain(extendedFiles, "gateway.request.afterBodyRead", { + request: { body: " " }, + }) + ).toMatchObject({ + matchedRuleIds: [], + outputKind: "pass", + warnings: [ + expect.objectContaining({ + severity: "warn", + code: "PLUGIN_REPLAY_REGEX_UNSUPPORTED", + }), + ], + }); + }); + it("replay explain reports block and warn matches without mutations", () => { const blockResult = replayHookExplain( rulePluginFilesWithAction({ kind: "block", reason: "blocked" }), From 43b82312e2d81de08db077c3004175f996e0ed2d Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 13:15:26 +0800 Subject: [PATCH 054/244] fix(plugin-devtools): replay unicode regex classes --- packages/create-aio-plugin/src/devtools.ts | 6 +++- .../create-aio-plugin/src/scaffold.test.ts | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index d2842fad..9a95bff0 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -1562,7 +1562,7 @@ function replayRegexFlags(matcher: Record, parsed: ReplayRegexP } if (parsed.enabledFlags.has("m")) flags.add("m"); if (parsed.enabledFlags.has("s")) flags.add("s"); - if (parsed.enabledFlags.has("u")) flags.add("u"); + if (parsed.enabledFlags.has("u") || usesUnicodePropertyEscape(parsed.pattern)) flags.add("u"); return Array.from(flags).join(""); } @@ -1574,6 +1574,10 @@ function hasUnsupportedExtendedReplayPattern(pattern: string, enabledFlags: Set< return enabledFlags.has("x") && (pattern.includes("#") || /\[[^\]]*\s[^\]]*\]/.test(pattern)); } +function usesUnicodePropertyEscape(pattern: string): boolean { + return /\\[pP]\{[^}]+\}/.test(pattern); +} + function replayRegexUnsupportedDiagnostic( message = "rule regex uses Rust regex syntax that replay cannot safely emulate" ): PluginDiagnostic { diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index eba2052e..7c36c2b3 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -1725,6 +1725,34 @@ describe("create-aio-plugin scaffold", () => { }); }); + it("replay handles Rust Unicode property classes without false passing", () => { + const files = rulePluginFilesWithTarget(undefined); + const document = JSON.parse(files["rules/main.json"] ?? "{}") as { + rules?: Array>; + }; + const rule = document.rules?.[0]; + if (rule) { + rule.match = { regex: "\\p{L}+" }; + } + files["rules/main.json"] = `${JSON.stringify(document, null, 2)}\n`; + + expect( + replayHookExplain(files, "gateway.request.afterBodyRead", { + request: { body: "é token" }, + }) + ).toMatchObject({ + matchedRuleIds: ["redact-token-rule"], + outputKind: "replace", + result: { action: "replace", requestBody: "[REDACTED] [REDACTED]" }, + }); + expect( + replayHook(files, "gateway.request.afterBodyRead", { request: { body: "é token" } }) + ).toEqual({ + action: "replace", + requestBody: "[REDACTED] [REDACTED]", + }); + }); + it("replay explain reports block and warn matches without mutations", () => { const blockResult = replayHookExplain( rulePluginFilesWithAction({ kind: "block", reason: "blocked" }), From 7fbb4e701f01f4ba0b8bbb11e081368b49c10688 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 13:27:37 +0800 Subject: [PATCH 055/244] feat(plugins): show runtime observability in plugin details --- src/pages/PluginsPage.tsx | 131 ++++++++++++++++++++--- src/pages/__tests__/PluginsPage.test.tsx | 101 +++++++++++++++++ 2 files changed, 218 insertions(+), 14 deletions(-) diff --git a/src/pages/PluginsPage.tsx b/src/pages/PluginsPage.tsx index 666ea9bf..88e167c0 100644 --- a/src/pages/PluginsPage.tsx +++ b/src/pages/PluginsPage.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { + Copy, Download, RotateCcw, Upload, @@ -12,6 +13,7 @@ import { ShieldAlert, Trash2, } from "lucide-react"; +import { copyText } from "../services/clipboard"; import { openDesktopSinglePath } from "../services/desktop/dialog"; import type { JsonValue, @@ -83,6 +85,13 @@ function jsonRecord(value: JsonValue): Record | null { return null; } +function detailValue(details: JsonValue, key: string) { + const value = jsonRecord(details)?.[key]; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + function isUnsigned(detail: PluginDetail) { return detail.audit_logs.some((log) => jsonRecord(log.details)?.unsigned === true); } @@ -117,6 +126,112 @@ function detailRows(detail: PluginDetail) { ]; } +function TraceIdButton({ traceId }: { traceId: string | null | undefined }) { + if (!traceId) return -; + const value = traceId; + + async function handleCopy() { + try { + await copyText(value); + toast.success("Trace ID 已复制"); + } catch (error) { + toast.error(formatActionFailureToast("复制 Trace ID", error).toast); + } + } + + return ( + + ); +} + +function RuntimeObservabilitySection({ detail }: { detail: PluginDetail }) { + const failures = detail.runtime_failures.slice(0, 5); + const auditLogs = detail.audit_logs.slice(0, 8); + const empty = failures.length === 0 && auditLogs.length === 0; + + return ( +
+ {empty ? ( +
+ 还没有记录到插件运行事件 +
+ ) : ( +
+ {failures.map((failure) => ( +
+
+ {failure.message} + + {failure.failure_kind} + +
+
+
+
Hook
+
+ {failure.hook_name ?? "-"} +
+
+
+
Trace ID
+ +
+
+
+ ))} + + {auditLogs.map((log) => { + const hookName = detailValue(log.details, "hookName"); + const failureKind = detailValue(log.details, "failureKind"); + + return ( +
+
+ {log.message} + + {log.risk_level} + +
+
+ {log.event_type} +
+
+
+
Hook
+
{hookName ?? "-"}
+
+
+
Failure
+
+ {failureKind ?? "-"} +
+
+
+
Trace ID
+ +
+
+
+ ); + })} +
+ )} +
+ ); +} + function PluginListRow({ plugin, active, @@ -355,6 +470,8 @@ function PluginDetailPanel({ /> + +
{detailRows(detail).map(([label, value]) => ( @@ -383,20 +500,6 @@ function PluginDetailPanel({
))}
- - {detail.audit_logs.length > 0 ? ( -
- {detail.audit_logs.slice(0, 5).map((log) => ( -
-
- {log.message} - {log.risk_level} -
-
{log.event_type}
-
- ))} -
- ) : null}
); diff --git a/src/pages/__tests__/PluginsPage.test.tsx b/src/pages/__tests__/PluginsPage.test.tsx index 3106ee19..57cc8c26 100644 --- a/src/pages/__tests__/PluginsPage.test.tsx +++ b/src/pages/__tests__/PluginsPage.test.tsx @@ -38,6 +38,8 @@ vi.mock("../../services/desktop/dialog", async () => { return { ...actual, openDesktopSinglePath: vi.fn() }; }); +vi.mock("../../services/clipboard", () => ({ copyText: vi.fn().mockResolvedValue(undefined) })); + vi.mock("../../query/plugins", async () => { const actual = await vi.importActual("../../query/plugins"); return { @@ -196,6 +198,105 @@ describe("pages/PluginsPage", () => { expect(screen.getByText("读取你发送给模型的内容")).toBeInTheDocument(); }); + it("renders runtime failures in the runtime observability section", async () => { + const { copyText } = await import("../../services/clipboard"); + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary()], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginQuery).mockReturnValue({ + data: detail({ + audit_logs: [], + runtime_failures: [ + { + id: 11, + plugin_id: "community.prompt-helper", + hook_name: "gateway.request.afterBodyRead", + failure_kind: "timeout", + message: "Hook timed out after 30s", + trace_id: "trace-runtime-1", + created_at: 41, + }, + ], + }), + isLoading: false, + isFetching: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText("运行观测")).toBeInTheDocument(); + expect(screen.getByText("Hook timed out after 30s")).toBeInTheDocument(); + expect(screen.getByText("timeout")).toBeInTheDocument(); + expect(screen.getAllByText("gateway.request.afterBodyRead").length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole("button", { name: /trace-runtime-1/ })); + + await waitFor(() => { + expect(copyText).toHaveBeenCalledWith("trace-runtime-1"); + expect(toast.success).toHaveBeenCalledWith("Trace ID 已复制"); + }); + }); + + it("renders audit logs with risk, event, trace, and detail fields", () => { + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary()], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginQuery).mockReturnValue({ + data: detail({ + runtime_failures: [], + audit_logs: [ + { + id: 12, + plugin_id: "community.prompt-helper", + trace_id: "trace-audit-1", + event_type: "plugin.hook.failed", + risk_level: "high", + message: "Plugin hook failed closed", + details: { hookName: "gateway.response.beforeSend", failureKind: "exception" }, + created_at: 42, + }, + ], + }), + isLoading: false, + isFetching: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText("plugin.hook.failed")).toBeInTheDocument(); + expect(screen.getByText("high")).toBeInTheDocument(); + expect(screen.getByText("trace-audit-1")).toBeInTheDocument(); + expect(screen.getByText("gateway.response.beforeSend")).toBeInTheDocument(); + expect(screen.getByText("exception")).toBeInTheDocument(); + }); + + it("shows an empty runtime observability state when no events were recorded", () => { + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary()], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginQuery).mockReturnValue({ + data: detail({ audit_logs: [], runtime_failures: [] }), + isLoading: false, + isFetching: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText("还没有记录到插件运行事件")).toBeInTheDocument(); + }); + it("disables plugin actions while config save is pending", () => { vi.mocked(usePluginsListQuery).mockReturnValue({ data: [summary({ status: "disabled", update_available: true })], From 4030daca5b7ff3ae7490939ec379294140f4e989 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 13:41:10 +0800 Subject: [PATCH 056/244] test(plugins): guard devtools contract drift --- scripts/check-plugin-api-contract.mjs | 72 +++++++++++++++++ .../check-plugin-api-contract.selftest.mjs | 77 +++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/scripts/check-plugin-api-contract.mjs b/scripts/check-plugin-api-contract.mjs index 78e91023..3c5914e3 100644 --- a/scripts/check-plugin-api-contract.mjs +++ b/scripts/check-plugin-api-contract.mjs @@ -180,6 +180,33 @@ function runtimeTokens(contract) { return [...contract.communityRuntimes, ...contract.policyGatedRuntimes]; } +function devtoolsPermissionTokens(contract) { + const developerToolPermissions = new Set([ + "request.body.read", + "request.body.write", + "response.body.read", + "response.body.write", + "stream.inspect", + "stream.modify", + "log.redact", + ]); + return (contract.activePermissions ?? []).filter((permission) => + developerToolPermissions.has(permission) + ); +} + +function devtoolsMutationFieldTokens(contract) { + const developerToolMutationFields = new Set([ + "requestBody", + "responseBody", + "streamChunk", + "logMessage", + ]); + return (contract.activeMutationFields ?? []).filter((field) => + developerToolMutationFields.has(field) + ); +} + function officialRuntimeTokens(contract) { return contract.officialRuntimes.flatMap((runtime) => runtime.split(":")); } @@ -291,6 +318,51 @@ if (contract) { "default scaffold contract token" ); + const devtools = readText("packages/create-aio-plugin/src/devtools.ts"); + requireIncludes( + "packages/create-aio-plugin/src/devtools.ts", + devtools, + contract.activeHooks, + "developer tool active hook" + ); + requireIncludes( + "packages/create-aio-plugin/src/devtools.ts", + devtools, + devtoolsPermissionTokens(contract), + "developer tool active permission" + ); + requireIncludes( + "packages/create-aio-plugin/src/devtools.ts", + devtools, + runtimeTokens(contract), + "developer tool runtime" + ); + requireIncludes( + "packages/create-aio-plugin/src/devtools.ts", + devtools, + devtoolsMutationFieldTokens(contract), + "developer tool mutation field" + ); + requireIncludes( + "packages/create-aio-plugin/src/devtools.ts", + devtools, + [ + "doctorPluginFiles", + "validatePluginFilesStrict", + "replayHookExplain", + "PLUGIN_RULE_PERMISSION_MISMATCH", + "PLUGIN_REPLAY_UNSUPPORTED_RUNTIME", + "PLUGIN_WASM_POLICY_GATED", + ], + "developer tool diagnostic surface" + ); + requireNotIncludes( + "packages/create-aio-plugin/src/devtools.ts", + devtools, + ["contextPatch"], + "legacy mutation field" + ); + const rustContract = readText("src-tauri/src/gateway/plugins/contract.rs"); requireIncludes( "src-tauri/src/gateway/plugins/contract.rs", diff --git a/scripts/check-plugin-api-contract.selftest.mjs b/scripts/check-plugin-api-contract.selftest.mjs index eb5220f6..dbaea7e2 100644 --- a/scripts/check-plugin-api-contract.selftest.mjs +++ b/scripts/check-plugin-api-contract.selftest.mjs @@ -28,6 +28,35 @@ function runCheck(root) { }); } +function writePassingDevtools(root) { + writeFileSync( + join(root, "packages/create-aio-plugin/src/devtools.ts"), + [ + [ + "gateway.request.afterBodyRead", + "gateway.request.beforeSend", + "gateway.response.chunk", + "gateway.response.after", + "gateway.error", + "log.beforePersist", + ].join(" "), + [ + "request.body.read", + "request.body.write", + "response.body.read", + "response.body.write", + "stream.inspect", + "stream.modify", + "log.redact", + ].join(" "), + "requestBody responseBody streamChunk logMessage headers", + "declarativeRules wasm", + "PLUGIN_RULE_PERMISSION_MISMATCH PLUGIN_REPLAY_UNSUPPORTED_RUNTIME PLUGIN_WASM_POLICY_GATED", + "validatePluginFilesStrict replayHookExplain doctorPluginFiles", + ].join("\n") + ); +} + function writePassingScaffold(root) { writeFileSync( join(root, "packages/plugin-sdk/src/index.ts"), @@ -143,6 +172,7 @@ function writePassingScaffold(root) { join(root, "packages/plugin-wasm-sdk/src/lib.rs"), 'request_body #[serde(rename_all = "camelCase")]' ); + writePassingDevtools(root); } const reservedHookRoot = makeRoot("reserved-hook"); @@ -336,6 +366,53 @@ if ( ); } +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: ["declarativeRules"], + policyGatedRuntimes: ["wasm"], + officialRuntimes: ["native:privacyFilter"], +}); +writePassingScaffold(missingDevtoolsMetadataRoot); +writeFileSync( + join(missingDevtoolsMetadataRoot, "packages/create-aio-plugin/src/devtools.ts"), + "declarativeRules validatePluginFilesStrict replayHookExplain doctorPluginFiles" +); + +const missingDevtoolsMetadataResult = runCheck(missingDevtoolsMetadataRoot); +if ( + missingDevtoolsMetadataResult.status === 0 || + !missingDevtoolsMetadataResult.stderr.includes("packages/create-aio-plugin/src/devtools.ts") || + !missingDevtoolsMetadataResult.stderr.includes("requestBody") +) { + throw new Error( + `expected devtools metadata failure, got status ${missingDevtoolsMetadataResult.status}\n${missingDevtoolsMetadataResult.stderr}` + ); +} + const globalPermissionDependencyRoot = makeRoot("global-permission-dependency"); writeJson(globalPermissionDependencyRoot, "docs/plugins/plugin-api-v1-contract.json", { apiVersion: "1.0.0", From ee4cc1dcdc6e14d58737936d028dbb5a9600bb69 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 14:07:41 +0800 Subject: [PATCH 057/244] fix(plugins): enforce full devtools contract drift checks --- packages/create-aio-plugin/src/devtools.ts | 107 +++++++++++++++--- scripts/check-plugin-api-contract.mjs | 31 +---- .../check-plugin-api-contract.selftest.mjs | 94 +++++++++++++++ 3 files changed, 190 insertions(+), 42 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 9a95bff0..c99d9cfd 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -12,6 +12,13 @@ import type { GatewayHookName, PluginManifest, ValidationResult } from "@aio-cod import { validateManifest } from "@aio-coding-hub/plugin-sdk"; import { createPluginScaffold, type ScaffoldFiles, type ScaffoldTemplate } from "./scaffold"; +type ActivePermission = Exclude< + PluginManifest["permissions"][number], + "plugin.storage" | "network.fetch" | "file.read" | "file.write" | "secret.read" +>; +type DeclarativeRuleMutationField = "requestBody" | "responseBody" | "streamChunk" | "logMessage"; +type ActiveMutationField = DeclarativeRuleMutationField | "headers"; + export type PackedPlugin = { bytes: Uint8Array; checksum: string; @@ -58,7 +65,7 @@ export type ReplayMutationSummary = | { changed: false } | { changed: true; - field: "requestBody" | "responseBody" | "streamChunk" | "logMessage"; + field: DeclarativeRuleMutationField; targetField: string; jsonPath?: string; }; @@ -88,6 +95,57 @@ type DoctorOptions = { strict?: boolean; }; +const PLUGIN_API_V1_ACTIVE_PERMISSIONS: readonly ActivePermission[] = [ + "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", +]; +const PLUGIN_API_V1_ACTIVE_MUTATION_FIELDS: readonly ActiveMutationField[] = [ + "requestBody", + "responseBody", + "streamChunk", + "logMessage", + "headers", +]; +const PLUGIN_API_V1_ACTIVE_PERMISSION_SET = new Set( + PLUGIN_API_V1_ACTIVE_PERMISSIONS +); +const PLUGIN_API_V1_ACTIVE_MUTATION_FIELD_SET = new Set( + PLUGIN_API_V1_ACTIVE_MUTATION_FIELDS +); +const DECLARATIVE_RULE_TARGETS = declarativeRuleTargets({ + "request.body": { + mutationField: "requestBody", + read: "request.body.read", + write: "request.body.write", + }, + "response.body": { + mutationField: "responseBody", + read: "response.body.read", + write: "response.body.write", + }, + "stream.chunk": { + mutationField: "streamChunk", + read: "stream.inspect", + write: "stream.modify", + }, + "log.message": { + mutationField: "logMessage", + read: "log.redact", + }, +} as const); +type DeclarativeRuleTargetField = keyof typeof DECLARATIVE_RULE_TARGETS; + const USAGE = [ "Usage:", " create-aio-plugin [rule|wasm]", @@ -415,7 +473,7 @@ function hasPluginFile(files: ScaffoldFiles, path: string): boolean { return Object.prototype.hasOwnProperty.call(files, path); } -const RULE_TARGET_FIELDS_BY_HOOK: Record = { +const RULE_TARGET_FIELDS_BY_HOOK: Record = { "gateway.request.afterBodyRead": ["request.body"], "gateway.request.beforeSend": ["request.body"], "gateway.response.after": ["response.body"], @@ -618,7 +676,12 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): diagnostics.push(...actionDiagnostics); const allowedFields = RULE_TARGET_FIELDS_BY_HOOK[hook] ?? []; let targetCompatible = true; - if (hook && targetField && allowedFields.length > 0 && !allowedFields.includes(targetField)) { + if ( + hook && + targetField && + allowedFields.length > 0 && + !allowedFields.some((field) => field === targetField) + ) { targetCompatible = false; diagnostics.push({ severity: "error", @@ -652,17 +715,35 @@ function strictRuleDiagnostics(files: ScaffoldFiles, manifest: PluginManifest): function permissionsForRuleTarget(field: string, actionKind: string): string[] { const mutates = actionKind === "replace" || actionKind === "appendMessage"; - switch (field) { - case "response.body": - return mutates ? ["response.body.read", "response.body.write"] : ["response.body.read"]; - case "stream.chunk": - return mutates ? ["stream.inspect", "stream.modify"] : ["stream.inspect"]; - case "log.message": - return ["log.redact"]; - case "request.body": - default: - return mutates ? ["request.body.read", "request.body.write"] : ["request.body.read"]; + const target = declarativeRuleTarget(field); + if (mutates && "write" in target) return [target.read, target.write]; + return [target.read]; +} + +function declarativeRuleTargets< + const Targets extends Record< + string, + { mutationField: ActiveMutationField; read: ActivePermission; write?: ActivePermission } + >, +>(targets: Targets): Targets { + for (const [field, target] of Object.entries(targets)) { + const writePermission = target.write; + if ( + !PLUGIN_API_V1_ACTIVE_MUTATION_FIELD_SET.has(target.mutationField) || + !PLUGIN_API_V1_ACTIVE_PERMISSION_SET.has(target.read) || + (writePermission !== undefined && !PLUGIN_API_V1_ACTIVE_PERMISSION_SET.has(writePermission)) + ) { + throw new Error(`declarative rule target is not aligned with Plugin API v1: ${field}`); + } } + return targets; +} + +function declarativeRuleTarget(field: string) { + return ( + DECLARATIVE_RULE_TARGETS[field as DeclarativeRuleTargetField] ?? + DECLARATIVE_RULE_TARGETS["request.body"] + ); } function ruleMatcherDiagnostics( diff --git a/scripts/check-plugin-api-contract.mjs b/scripts/check-plugin-api-contract.mjs index 3c5914e3..119d38e0 100644 --- a/scripts/check-plugin-api-contract.mjs +++ b/scripts/check-plugin-api-contract.mjs @@ -180,33 +180,6 @@ function runtimeTokens(contract) { return [...contract.communityRuntimes, ...contract.policyGatedRuntimes]; } -function devtoolsPermissionTokens(contract) { - const developerToolPermissions = new Set([ - "request.body.read", - "request.body.write", - "response.body.read", - "response.body.write", - "stream.inspect", - "stream.modify", - "log.redact", - ]); - return (contract.activePermissions ?? []).filter((permission) => - developerToolPermissions.has(permission) - ); -} - -function devtoolsMutationFieldTokens(contract) { - const developerToolMutationFields = new Set([ - "requestBody", - "responseBody", - "streamChunk", - "logMessage", - ]); - return (contract.activeMutationFields ?? []).filter((field) => - developerToolMutationFields.has(field) - ); -} - function officialRuntimeTokens(contract) { return contract.officialRuntimes.flatMap((runtime) => runtime.split(":")); } @@ -328,7 +301,7 @@ if (contract) { requireIncludes( "packages/create-aio-plugin/src/devtools.ts", devtools, - devtoolsPermissionTokens(contract), + contract.activePermissions, "developer tool active permission" ); requireIncludes( @@ -340,7 +313,7 @@ if (contract) { requireIncludes( "packages/create-aio-plugin/src/devtools.ts", devtools, - devtoolsMutationFieldTokens(contract), + contract.activeMutationFields ?? [], "developer tool mutation field" ); requireIncludes( diff --git a/scripts/check-plugin-api-contract.selftest.mjs b/scripts/check-plugin-api-contract.selftest.mjs index dbaea7e2..0b126f33 100644 --- a/scripts/check-plugin-api-contract.selftest.mjs +++ b/scripts/check-plugin-api-contract.selftest.mjs @@ -413,6 +413,100 @@ if ( ); } +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: ["declarativeRules"], + policyGatedRuntimes: ["wasm"], + officialRuntimes: ["native:privacyFilter"], +}); +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(partialDevtoolsMetadataRoot, "src-tauri/src/domain/plugins.rs"), + [ + "declarativeRules wasm native privacyFilter", + "crate::gateway::plugins::contract::is_active_hook", + "crate::gateway::plugins::contract::is_reserved_hook", + "crate::gateway::plugins::contract::is_reserved_permission", + "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" }', + 'pub fn is_reserved_permission(permission: &str) -> bool { permission == "network.fetch" }', + "fn permission_risk(permission: &str) { request.meta.read; request.body.read; network.fetch; }", + "PLUGIN_RESERVED_HOOK PLUGIN_RESERVED_PERMISSION", + ].join("\n") +); +writeFileSync( + join(partialDevtoolsMetadataRoot, "docs/plugin-manifest-v1.md"), + "gateway.request.afterBodyRead gateway.response.headers request.meta.read request.body.read network.fetch" +); +writeFileSync( + join(partialDevtoolsMetadataRoot, "docs/plugins/reference/permissions.md"), + "request.meta.read request.body.read network.fetch" +); +writeFileSync( + join(partialDevtoolsMetadataRoot, "packages/plugin-wasm-sdk/src/lib.rs"), + 'request_body headers #[serde(rename_all = "camelCase")]' +); +writeFileSync( + join(partialDevtoolsMetadataRoot, "packages/create-aio-plugin/src/devtools.ts"), + [ + "gateway.request.afterBodyRead", + "request.body.read", + "requestBody", + "declarativeRules wasm", + "PLUGIN_RULE_PERMISSION_MISMATCH PLUGIN_REPLAY_UNSUPPORTED_RUNTIME PLUGIN_WASM_POLICY_GATED", + "validatePluginFilesStrict replayHookExplain doctorPluginFiles", + ].join("\n") +); + +const partialDevtoolsMetadataResult = runCheck(partialDevtoolsMetadataRoot); +if ( + partialDevtoolsMetadataResult.status === 0 || + !partialDevtoolsMetadataResult.stderr.includes( + "packages/create-aio-plugin/src/devtools.ts is missing developer tool active permission request.meta.read" + ) || + !partialDevtoolsMetadataResult.stderr.includes( + "packages/create-aio-plugin/src/devtools.ts is missing developer tool mutation field headers" + ) +) { + throw new Error( + `expected full devtools metadata failure, got status ${partialDevtoolsMetadataResult.status}\n${partialDevtoolsMetadataResult.stderr}` + ); +} + const globalPermissionDependencyRoot = makeRoot("global-permission-dependency"); writeJson(globalPermissionDependencyRoot, "docs/plugins/plugin-api-v1-contract.json", { apiVersion: "1.0.0", From 6c693ab2fe4f6450f70b2e3136443e19a9fa8cd4 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 14:47:31 +0800 Subject: [PATCH 058/244] test(provider): add internal acceptance coverage --- src-tauri/src/domain/provider_oauth_limits.rs | 37 ++++++++++++++++++ .../gateway/proxy/handler/provider_order.rs | 9 +++++ .../proxy/handler/provider_selection.rs | 3 +- .../proxy/handler/provider_selection/tests.rs | 38 ++++++++++++++++++- .../proxy/protocol_bridge/e2e_tests.rs | 36 ++++++++++++++++++ 5 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/domain/provider_oauth_limits.rs b/src-tauri/src/domain/provider_oauth_limits.rs index 2f325184..cd02b3c1 100644 --- a/src-tauri/src/domain/provider_oauth_limits.rs +++ b/src-tauri/src/domain/provider_oauth_limits.rs @@ -607,4 +607,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/gateway/proxy/handler/provider_order.rs b/src-tauri/src/gateway/proxy/handler/provider_order.rs index e4004490..75627912 100644 --- a/src-tauri/src/gateway/proxy/handler/provider_order.rs +++ b/src-tauri/src/gateway/proxy/handler/provider_order.rs @@ -106,6 +106,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..079a6c3e 100644 --- a/src-tauri/src/gateway/proxy/handler/provider_selection/tests.rs +++ b/src-tauri/src/gateway/proxy/handler/provider_selection/tests.rs @@ -304,7 +304,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 +335,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 +344,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/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] From 95015e934469872da0856d498996879614a14344 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 15:02:18 +0800 Subject: [PATCH 059/244] docs(plugins): document 0.62.1 developer loop --- docs/plugins/developer-guide.md | 28 +++++++++++++-------- docs/plugins/reference/compatibility.md | 8 ++++++ docs/plugins/reference/declarative-rules.md | 27 ++++++++++++++++++++ docs/plugins/reference/sdk.md | 17 +++++++++++-- 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/docs/plugins/developer-guide.md b/docs/plugins/developer-guide.md index 38400044..b500aab8 100644 --- a/docs/plugins/developer-guide.md +++ b/docs/plugins/developer-guide.md @@ -19,8 +19,8 @@ 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 上验证行为。 +5. 使用 `pnpm create-aio-plugin doctor` 和 `validate --strict` 做 package health、manifest 与规则校验。 +6. 使用 `pnpm create-aio-plugin replay --explain` 在本地 fixture 上验证行为并查看规则解释。 7. 使用 `pnpm create-aio-plugin pack` 打包为 `.aio-plugin`。 8. 在 Plugins 页面本地导入,授权权限,启用插件,检查审计日志。 9. 发布前计算 `sha256`,可信索引分发时补 Ed25519 签名。 @@ -34,10 +34,11 @@ pnpm --filter create-aio-plugin test pnpm create-aio-plugin acme.redactor rule ``` -生成目录后,先校验 `plugin.json` 和规则文件: +生成目录后,先检查 package health,再校验 `plugin.json` 和规则文件: ```bash -pnpm create-aio-plugin validate ./acme.redactor +pnpm create-aio-plugin doctor ./acme.redactor +pnpm create-aio-plugin validate --strict ./acme.redactor ``` 添加 Claude 和 Codex request shapes 作为 replay fixtures。Claude fixture 示例: @@ -60,11 +61,11 @@ Codex/OpenAI Responses fixture 示例: } ``` -在本地回放两个 fixtures: +在本地解释回放两个 fixtures: ```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 +pnpm create-aio-plugin replay --explain ./acme.redactor ./fixtures/claude-request.json gateway.request.afterBodyRead +pnpm create-aio-plugin replay --explain ./acme.redactor ./fixtures/codex-request.json gateway.request.afterBodyRead ``` 打包插件并从 Plugins 页面本地安装: @@ -241,12 +242,19 @@ Claude 和 Codex/OpenAI Responses 的请求结构不同。插件应避免只适 常用命令: ```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 --filter create-aio-plugin test +pnpm create-aio-plugin acme.redactor rule +pnpm create-aio-plugin doctor ./acme.redactor +pnpm create-aio-plugin validate --strict ./acme.redactor +pnpm create-aio-plugin replay --explain ./acme.redactor ./fixtures/claude-request.json gateway.request.afterBodyRead +pnpm create-aio-plugin replay --explain ./acme.redactor ./fixtures/codex-request.json gateway.request.afterBodyRead pnpm create-aio-plugin pack ./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 rule files, rule hooks, target compatibility, and rule permission mismatches. +Warnings do not fail the command in 0.62.1; any `error` severity diagnostic returns a non-zero exit code. + 验收一个插件时,至少确认: - `plugin.json` 能通过校验。 diff --git a/docs/plugins/reference/compatibility.md b/docs/plugins/reference/compatibility.md index 477b93b6..db7cffaf 100644 --- a/docs/plugins/reference/compatibility.md +++ b/docs/plugins/reference/compatibility.md @@ -25,3 +25,11 @@ Plugin API v1 remains externally compatible in 0.62. 这个版本重组的是宿 0.62 does not add public provider plugin APIs. Provider adapter 仍是 host-internal 设计,用于先降低 gateway/provider 分支扩散和维护成本;未来是否公开 provider 插件 API,需要另行设计版本化契约。 0.62 keeps third-party JavaScript and WebView plugin execution unsupported. 社区插件继续使用 `declarativeRules`;WASM 仍受宿主策略控制,未启用时安装或执行会被拒绝。 + +## 0.62.1 Developer Loop Boundary + +0.62.1 does not change Plugin API v1. `doctor`, `validate --strict`, and `replay --explain` are developer tooling around the same manifest and hook contract. + +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. + +WASM remains policy-gated. The scaffold and pack flow can carry WASM artifacts, but marketplace WASM execution is not enabled by default. diff --git a/docs/plugins/reference/declarative-rules.md b/docs/plugins/reference/declarative-rules.md index d26c071f..3047a920 100644 --- a/docs/plugins/reference/declarative-rules.md +++ b/docs/plugins/reference/declarative-rules.md @@ -180,6 +180,33 @@ Capture groups 使用 Rust regex replacement syntax: Replay 支持社区规则运行时相同的 v1.1 rule actions:`replace`、`block`、`warn` 和 `appendMessage`。对 request body rewrites,它支持 raw text targets,也支持文档化 JSONPath 子集,例如 `$.messages[*].content`、`$.input[*].content[*].text` 和 `$.input`。 +`replay --explain` 会返回规则评估和 mutation 摘要: + +```json +{ + "pluginId": "acme.redactor", + "runtime": "declarativeRules", + "hook": "gateway.request.afterBodyRead", + "evaluatedRuleCount": 1, + "matchedRuleIds": ["redact-token-rule"], + "actionKind": "replace", + "outputKind": "replace", + "mutationSummary": { + "changed": true, + "field": "requestBody", + "targetField": "request.body", + "jsonPath": "$.messages[*].content" + }, + "warnings": [], + "result": { + "action": "replace", + "requestBody": "{\"messages\":[{\"role\":\"user\",\"content\":\"[REDACTED]\"}]}" + } +} +``` + +`replay --explain` is a deterministic local simulator for the supported declarative-rules subset. The Rust gateway remains the source of truth for runtime execution, audit events, failure policy, timeouts, and circuit behavior. + ## 适合场景 - 通过追加 instructions 做 prompt optimization。 diff --git a/docs/plugins/reference/sdk.md b/docs/plugins/reference/sdk.md index e4c3ecc6..bf1b8707 100644 --- a/docs/plugins/reference/sdk.md +++ b/docs/plugins/reference/sdk.md @@ -48,11 +48,24 @@ TypeScript SDK 导出: `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 doctor ./acme.redactor +pnpm create-aio-plugin validate --strict ./acme.redactor +pnpm create-aio-plugin replay --explain ./acme.redactor ./fixtures/request.json gateway.request.afterBodyRead pnpm create-aio-plugin pack ./acme.redactor ``` +开发工具的诊断对象使用稳定 shape,便于 CLI、GUI 和测试共享: + +```json +{ + "severity": "error", + "code": "PLUGIN_RULE_PERMISSION_MISMATCH", + "message": "rule targeting request.body with action replace requires request.body.write", + "path": "rules/main.json#/rules/0", + "hint": "Add request.body.write to manifest.permissions or change the rule target/action." +} +``` + Rust/WASM SDK 导出: - `PluginManifest` From 1e1c7d8f032372577051e9867228a5d118195e11 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 15:38:45 +0800 Subject: [PATCH 060/244] fix(plugin-devtools): preserve legacy replay regex behavior --- ...coding-hub-0-62-1-plugin-developer-loop.md | 115 ++++++++++++++++-- packages/create-aio-plugin/src/devtools.ts | 35 +++++- .../create-aio-plugin/src/scaffold.test.ts | 7 +- 3 files changed, 143 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-1-plugin-developer-loop.md b/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-1-plugin-developer-loop.md index d876bb6e..b86f2590 100644 --- a/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-1-plugin-developer-loop.md +++ b/docs/superpowers/plans/2026-06-22-aio-coding-hub-0-62-1-plugin-developer-loop.md @@ -788,6 +788,57 @@ it("replay explain command emits JSON explanation", () => { matchedRuleIds: ["redact-token-rule"], }); }); + +it("legacy replay keeps JavaScript best-effort regex behavior while explain reports Rust-aware diagnostics", () => { + const toggledFiles = rulePluginFilesWithTarget(undefined); + const toggledDocument = JSON.parse(toggledFiles["rules/main.json"] ?? "{}") as { + rules?: Array>; + }; + const toggledRule = toggledDocument.rules?.[0]; + if (toggledRule) { + toggledRule.match = { regex: "(?i)sec(?-i)ret" }; + } + toggledFiles["rules/main.json"] = `${JSON.stringify(toggledDocument, null, 2)}\n`; + + expect( + replayHook(toggledFiles, "gateway.request.afterBodyRead", { + request: { body: "SECRET token" }, + }) + ).toEqual({ action: "pass" }); + expect( + replayHookExplain(toggledFiles, "gateway.request.afterBodyRead", { + request: { body: "SECRET token" }, + }) + ).toMatchObject({ + outputKind: "pass", + warnings: [expect.objectContaining({ code: "PLUGIN_REPLAY_REGEX_UNSUPPORTED" })], + }); + + const unicodeFiles = rulePluginFilesWithTarget(undefined); + const unicodeDocument = JSON.parse(unicodeFiles["rules/main.json"] ?? "{}") as { + rules?: Array>; + }; + const unicodeRule = unicodeDocument.rules?.[0]; + if (unicodeRule) { + unicodeRule.match = { regex: "\\p{L}+" }; + } + unicodeFiles["rules/main.json"] = `${JSON.stringify(unicodeDocument, null, 2)}\n`; + + expect( + replayHook(unicodeFiles, "gateway.request.afterBodyRead", { + request: { body: "é token" }, + }) + ).toEqual({ action: "pass" }); + expect( + replayHookExplain(unicodeFiles, "gateway.request.afterBodyRead", { + request: { body: "é token" }, + }) + ).toMatchObject({ + matchedRuleIds: ["redact-token-rule"], + outputKind: "replace", + result: { action: "replace", requestBody: "[REDACTED] [REDACTED]" }, + }); +}); ``` Update `rulePluginFilesWithRule` so test rule ids are stable and not coupled to scaffold text: @@ -844,6 +895,7 @@ type ReplayRuleTrace = { actionKind: string | null; targetField?: string; jsonPath?: string; + warning?: PluginDiagnostic; }; ``` @@ -917,6 +969,7 @@ export function replayHookExplain( for (const [index, rule] of (document.rules ?? []).entries()) { evaluatedRuleCount += 1; const trace = replayDeclarativeRuleWithTrace(rule, hook, context, `${rulePath}#${index + 1}`); + if (trace.warning) warnings.push(trace.warning); if (trace.matched) matchedRuleIds.push(trace.ruleId); if (trace.result.action !== "pass") { finalTrace = trace; @@ -942,15 +995,52 @@ export function replayHookExplain( } ``` -- [ ] **Step 4: Instrument declarative rule replay without changing `replayHook` output** +- [ ] **Step 4: Add an explain-only trace path and keep legacy `replayHook` on its old best-effort path** -Replace the body of `replayDeclarativeRule` with: +Keep `replayDeclarativeRule` as the legacy implementation. It must continue compiling regexes with JavaScript's `RegExp` directly and silently pass when JavaScript cannot compile a pattern. This preserves the old local replay contract: ```ts -return replayDeclarativeRuleWithTrace(rawRule, hook, context, "rule").result; +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" }; + if (typeof matcher.regex !== "string") return { action: "pass" }; + + let regex: RegExp; + try { + regex = new RegExp(matcher.regex, matcher.caseSensitive === false ? "gi" : "g"); + } catch { + 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); + } + return replayJsonPathAction(text, path, regex, action, targetField); +} ``` -Add this helper before `replayDeclarativeRule`: +Add the new explain-only helper after `replayDeclarativeRule`: ```ts function replayDeclarativeRuleWithTrace( @@ -979,10 +1069,19 @@ function replayDeclarativeRuleWithTrace( if (!target || !matcher || !action) { return { result: { action: "pass" }, matched: false, ruleId, actionKind, targetField, jsonPath }; } - const regex = compileReplayRegex(matcher); - if (!regex) { - return { result: { action: "pass" }, matched: false, ruleId, actionKind, targetField, jsonPath }; + const compiled = compileReplayRegex(matcher); + if (!compiled.ok) { + return { + result: { action: "pass" }, + matched: false, + ruleId, + actionKind, + targetField, + jsonPath, + ...(compiled.warning ? { warning: compiled.warning } : {}), + }; } + const regex = compiled.regex; const text = textFromFixture(context, targetField); if (!text) { @@ -1057,6 +1156,8 @@ function summaryForReplacement( } ``` +`compileReplayRegex`, `parseReplayRegexPattern`, and `replayRegexFlags` belong only to the explain path. Do not call them from `replayDeclarativeRule` or legacy `replayHook`. + - [ ] **Step 5: Run tests to verify the task passes** Run: diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index c99d9cfd..0a22800c 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -1508,9 +1508,38 @@ function replayDeclarativeRule( hook: GatewayHookName, context: unknown ): ReplayRuleResult { - const trace = replayDeclarativeRuleWithTrace(rawRule, hook, context); - if (trace.warning) throw new Error(`${trace.warning.code}: ${trace.warning.message}`); - return trace.result; + 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" }; + if (typeof matcher.regex !== "string") return { action: "pass" }; + + let regex: RegExp; + try { + regex = new RegExp(matcher.regex, matcher.caseSensitive === false ? "gi" : "g"); + } catch { + 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); + } + return replayJsonPathAction(text, path, regex, action, targetField); } function replayDeclarativeRuleWithTrace( diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index 7c36c2b3..a07f33d7 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -1693,11 +1693,11 @@ describe("create-aio-plugin scaffold", () => { }), ], }); - expect(() => + expect( replayHook(toggledFiles, "gateway.request.afterBodyRead", { request: { body: "SECRET token" }, }) - ).toThrow(/PLUGIN_REPLAY_REGEX_UNSUPPORTED/); + ).toEqual({ action: "pass" }); const extendedFiles = rulePluginFilesWithTarget(undefined); const extendedDocument = JSON.parse(extendedFiles["rules/main.json"] ?? "{}") as { @@ -1748,8 +1748,7 @@ describe("create-aio-plugin scaffold", () => { expect( replayHook(files, "gateway.request.afterBodyRead", { request: { body: "é token" } }) ).toEqual({ - action: "replace", - requestBody: "[REDACTED] [REDACTED]", + action: "pass", }); }); From 13bd28a4735f60de433e8dd7fcf1f4289c4e16ed Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 20:00:18 +0800 Subject: [PATCH 061/244] docs(plugins): design 0.62.2 lifecycle release --- ...ding-hub-0-62-2-plugin-lifecycle-design.md | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-23-aio-coding-hub-0-62-2-plugin-lifecycle-design.md diff --git a/docs/superpowers/specs/2026-06-23-aio-coding-hub-0-62-2-plugin-lifecycle-design.md b/docs/superpowers/specs/2026-06-23-aio-coding-hub-0-62-2-plugin-lifecycle-design.md new file mode 100644 index 00000000..ea0db20b --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-aio-coding-hub-0-62-2-plugin-lifecycle-design.md @@ -0,0 +1,386 @@ +# aio-coding-hub 0.62.2 Plugin Distribution and Lifecycle Design + +日期:2026-06-23 + +## Summary + +0.62.2 是插件分发与生命周期稳定版。0.62.0 稳定 Gateway-first 插件内核,0.62.1 补齐插件开发者闭环和运行观测,0.62.2 继续保持 Plugin API v1 外部兼容,把已经存在但分散的安装、更新、远程包、回滚、隔离和风险解释能力收口成一个可理解、可测试、可维护的生命周期层。 + +本版本不追求新增插件执行能力。核心目标是让用户在安装或更新插件前知道“这个插件是什么、会影响哪里、有什么风险、是否兼容、是否可信、能不能回滚”;让宿主在插件出问题或被撤销时能给出稳定状态和恢复路径。 + +## Current State + +当前分支已经具备 0.62.2 的基础: + +- `PluginManifest` 已包含 `description`、`author`、`homepage`、`repository`、`license`、`checksum`、`signature`、`category` 等发布相关元数据。 +- `PluginStatus` 已包含 `available`、`installed`、`enabled`、`disabled`、`update_available`、`incompatible`、`quarantined`、`uninstalled`。 +- `PluginInstallSource` 已区分 `local`、`market`、`github_release`、`offline`、`official`。 +- 本地 `.aio-plugin` 包安装已经支持安全解压、manifest 校验、checksum、signature policy、高风险 unsigned package 拦截、安装目录 materialize。 +- 远程包安装已经支持受限 URL、checksum required、market/GitHub release source、签名校验。 +- market index parsing 已支持 compatible、update_available、revoked、risk_labels、install_block_reason。 +- 更新流程已经保存 permission delta:旧授权继续保留,新权限进入 pending。 +- repository 已保存 `plugin_versions`,服务层已有 rollback 到指定版本的能力。 +- quarantine 已存在,用于 revoked plugin 或其他宿主判定的强制隔离。 +- GUI 插件页已展示安装来源、状态、风险、运行观测、更新入口、回滚入口和官方插件安装入口。 + +主要缺口不是能力完全没有,而是边界和产品语义还不够集中: + +- 安装前没有统一 preview model,GUI 和命令层难以一致展示风险、兼容性和信任状态。 +- 更新前 diff 还没有成为一等概念,permission delta、hook/runtime/config 变化没有统一解释。 +- lifecycle status 的来源、转换和用户可执行动作需要更明确。 +- local、remote、market、GitHub release、official 的安装结果缺少统一 lifecycle summary。 +- rollback 和 quarantine 已有基础,但 GUI、测试和文档还没有形成完整验收闭环。 + +## Goals + +0.62.2 必须交付: + +1. 统一的插件安装前预检模型。 +2. 统一的插件更新 diff 模型。 +3. 更清晰的插件生命周期状态和可执行动作。 +4. GUI 中可理解的生命周期面板。 +5. local、remote、market、GitHub release、official 安装路径的一致验收。 +6. rollback、quarantine、permission delta、compatibility、checksum/signature 的测试覆盖。 +7. 面向用户和插件作者的生命周期文档。 + +## Non-Goals + +0.62.2 不做: + +- 不改变 Plugin API v1 manifest shape。 +- 不引入 Plugin API v2。 +- 不开放 Provider Plugin API。 +- 不开放 JS、TypeScript 或 WebView 插件 runtime。 +- 不把 Tauri2 GUI 变成 browser-like plugin container。 +- 不默认开放任意 marketplace WASM 执行。 +- 不做完整插件市场产品化。 +- 不做自动后台更新。 +- 不做复杂账号体系、评分、评论、推荐、支付或远程运营后台。 +- 不让插件控制 provider selection、failover、OAuth、token counting、session binding。 + +## Product Direction + +目标体验分成三个场景。 + +### 1. 安装前 + +用户导入本地 `.aio-plugin`、安装官方插件、从远程包安装或从 market listing 安装前,宿主先给出 preview: + +- 插件名称、id、版本、描述、作者、license、homepage/repository。 +- runtime 类型和当前宿主是否支持。 +- hooks 列表、优先级和 failure policy。 +- permissions 列表、风险等级和简短说明。 +- hostCompatibility 是否满足当前 app/pluginApi/platform。 +- package checksum 是否匹配。 +- signature 是否已验证、未签名是否允许。 +- 安装来源:local、official、market、github_release、offline。 +- 是否会覆盖已有插件。 +- 是否存在阻塞原因。 + +用户看到的是“能不能安装”和“为什么”,而不是裸错误字符串。 + +### 2. 更新前 + +用户更新插件前,宿主给出 diff: + +- fromVersion -> toVersion。 +- runtime 是否变化。 +- hooks 是否新增、删除或 priority/failure policy 变化。 +- permissions 是否新增、删除、仍已授权或变成 pending。 +- configVersion 是否变化。 +- compatibility 范围是否变化。 +- package trust 是否变化:checksum、signature、source。 +- 是否可回滚到当前版本。 + +新增权限必须进入 pending,不能因为更新自动获得新权限。更新后如果插件处于 enabled,但新增必需权限未授权,宿主应拒绝继续启用或要求用户处理 pending permissions。 + +### 3. 出问题后 + +插件出问题时,用户能看到稳定状态和恢复动作: + +- runtime failure 仍在 0.62.1 的运行观测中展示。 +- revoked 或宿主判定危险的插件进入 quarantined。 +- quarantined 插件不能 enable。 +- rollback 只允许回到 repository 已记录的历史版本。 +- uninstall 保留 audit,不物理删除所有历史证据。 +- 关键状态变化都写入 audit log。 + +## Architecture + +0.62.2 增加的是 lifecycle layer,不是新的 runtime layer。 + +### 1. Package Inspection + +新增或整理一个 host-owned inspection 入口,用于读取 `.aio-plugin` 或 market listing 并返回 preview。它应该复用现有 package extraction、manifest validation、checksum/signature verification、market compatibility evaluation,不重复定义规则。 + +建议模型: + +```text +PluginInstallPreview + plugin identity + package source + manifest summary + compatibility result + runtime support result + permission risk summary + hook summary + trust summary + existing install summary + blocking reasons + warnings +``` + +preview 可以由 local package、remote package bytes 或 market listing 生成。remote package 仍然必须在下载后用 checksum 验证,不因为 preview 放宽安装策略。 + +### 2. Update Diff + +新增或整理一个 update diff builder,用当前已安装 `PluginDetail` 和待安装 manifest/package 生成差异。这个 builder 不负责写数据库,只负责给安装服务和 GUI 提供一致解释。 + +建议模型: + +```text +PluginUpdateDiff + pluginId + fromVersion + toVersion + versionDirection + runtimeChange + hookChanges + permissionChanges + configVersionChange + compatibilityChange + trustChange + rollbackAvailable + blockingReasons + warnings +``` + +`permissionChanges` 至少区分: + +- unchangedGranted +- unchangedPending +- addedPending +- removed + +### 3. Lifecycle Service + +`plugin_service` 继续负责真实安装、更新、回滚、隔离、启用、禁用和卸载。0.62.2 的调整重点是让这些操作共享同一套 preview/diff/lifecycle summary,而不是在每条路径里各自拼接审计细节。 + +建议内部边界: + +- package layer:安全解压、读取 manifest、计算 checksum。 +- trust layer:checksum/signature/public key policy。 +- compatibility layer:host/pluginApi/platform/runtime support。 +- diff layer:当前插件 vs 待安装插件。 +- lifecycle service:执行状态转换并写 audit。 +- GUI/query layer:展示 preview、diff 和 detail,不推断规则。 + +### 4. GUI Lifecycle Panel + +GUI 插件详情页需要增加生命周期视角,而不是只展示 manifest 字段。 + +建议区域: + +- 当前状态:enabled/disabled/update available/quarantined/incompatible。 +- 来源与信任:install source、checksum、signature verified/unsigned、developer mode。 +- 版本与回滚:current version、previous versions、rollback action。 +- 更新影响:如果 update_available,展示待更新来源和 diff 摘要。 +- 权限变化:granted、pending、added on update。 +- 隔离原因:quarantine reason 和最后 audit。 +- 可执行动作:enable、disable、update、rollback、uninstall,按状态禁用不合法动作。 + +GUI 不需要做完整 marketplace 页面。0.62.2 只要求插件详情和导入/更新流程能解释 lifecycle。 + +## Functional Scope + +### Required + +- 添加插件安装前 preview 能力。 +- 添加插件更新 diff 能力。 +- 统一安装、更新、回滚、quarantine 的 lifecycle summary。 +- GUI 展示安装前风险和更新前差异。 +- GUI 展示 quarantine reason、rollback availability、source/trust summary。 +- 保证 enabled 插件更新后新增权限不会自动授权。 +- 保证 quarantined 和 incompatible 插件不能启用。 +- 补齐 service、command、GUI、docs 测试。 + +### Optional + +- 展示插件历史版本列表。 +- 从 audit 中提取更友好的 last lifecycle event。 +- 本地 package preview 支持命令或开发工具输出 JSON。 +- 对 rollback 增加确认摘要。 + +### Deferred + +- 完整 marketplace 页面。 +- 自动后台检查更新。 +- 插件源 CRUD GUI。 +- 签名 key 管理 GUI。 +- WASM lifecycle 特化 UI。 +- Provider plugin lifecycle。 + +## Data Flow + +### Local Package Install + +```text +.aio-plugin file + -> package inspection + -> manifest validation + -> compatibility/runtime/trust evaluation + -> install preview + -> user confirmation + -> install_plugin_from_local_package + -> lifecycle summary + audit + -> plugin detail refresh +``` + +### Local Package Update + +```text +.aio-plugin file + -> package inspection + -> current plugin detail + -> update diff + -> user confirmation + -> update_plugin_from_local_package + -> permission delta persisted + -> lifecycle summary + audit + -> plugin detail refresh +``` + +### Remote Package Install + +```text +market listing or explicit remote input + -> download package bytes + -> checksum verification required + -> signature verification when policy provides signature/public key + -> install preview or update diff + -> user confirmation + -> install_plugin_from_remote_package_bytes + -> lifecycle summary + audit +``` + +### Quarantine + +```text +market revoked or host policy decision + -> quarantine_revoked_plugin + -> status = quarantined + -> enable blocked + -> audit event retained + -> GUI shows reason and recovery options +``` + +### Rollback + +```text +selected previous version + -> repository.get_plugin_version + -> update_plugin_manifest + -> status and permissions remain policy-safe + -> audit event retained + -> gateway plugin refresh +``` + +## Error Handling + +Lifecycle operations should return stable codes where possible. GUI can translate codes into short Chinese messages while preserving technical detail in logs. + +Important error classes: + +- package unreadable or invalid archive; +- missing or invalid `plugin.json`; +- incompatible host/app/pluginApi/platform; +- unsupported runtime or disabled runtime policy; +- checksum mismatch; +- signature policy incomplete; +- unsigned high-risk package rejected; +- plugin id mismatch; +- update targets a different plugin id; +- update is downgrade unless explicitly allowed by rollback path; +- plugin is quarantined; +- plugin has pending required permissions; +- rollback version not found. + +Preview should report blockers and warnings without mutating state. Install/update should enforce the same blockers again; preview is user experience, not security boundary. + +## Compatibility + +Plugin API v1 remains externally compatible. 0.62.2 may add host-side preview/diff command models, GUI models, docs and tests, but must not require plugin authors to change valid v1 manifests. + +Existing local/offline packages that install today should continue to install unless they already violate current host policy. `validate --strict` from 0.62.1 remains the developer-side package health check; 0.62.2 lifecycle preview is host-side install/update explanation. + +Provider Plugin API remains private. WASM remains policy-gated. + +## Testing Strategy + +### Rust Service Tests + +Cover: + +- local package preview returns manifest, runtime, hooks, permissions, risk, compatibility and trust summary; +- preview reports missing manifest without installing; +- preview reports incompatible hostCompatibility; +- preview reports unsupported runtime policy; +- install enforces checksum/signature policy after preview; +- update diff reports version, hook, runtime, permission and configVersion changes; +- update preserves existing granted permissions and moves new permissions to pending; +- quarantined plugin cannot be enabled; +- rollback loads a recorded historical version; +- remote install requires checksum; +- revoked market listing maps to blocked/quarantine flow. + +### Command Tests + +Cover: + +- generated Tauri command bindings expose preview/diff inputs and outputs; +- local preview command does not mutate DB; +- update preview command fails clearly when package id does not match selected plugin; +- remote install path keeps existing URL/checksum restrictions. + +### Frontend Tests + +Cover: + +- install preview renders plugin identity, source, compatibility, runtime, permission risk and blockers; +- update diff renders permission delta and version change; +- quarantine reason renders and enable action is unavailable; +- rollback action is only shown when a previous version is available; +- pending permissions after update are visible before enable; +- unsigned/high-risk warning is visible when present. + +### Existing Gates + +Continue running: + +- `pnpm --filter create-aio-plugin test` +- `pnpm --filter create-aio-plugin typecheck` +- `pnpm check:plugin-api-contract` +- `pnpm check:plugin-system-docs` +- `pnpm test:unit -- src/pages/__tests__/PluginsPage.test.tsx` +- `pnpm typecheck` +- `cd src-tauri && cargo test plugin --lib` +- `cd src-tauri && cargo test provider --lib` +- `pnpm check:prepush` + +## Acceptance Criteria + +0.62.2 可以验收时应满足: + +1. 用户安装插件前能看到统一 preview,不需要从错误 toast 猜风险。 +2. 用户更新插件前能看到清晰 diff,尤其是新增权限和 runtime/hook/config 变化。 +3. 新增权限不会在更新时被自动授权。 +4. quarantined/incompatible 插件不能被启用。 +5. rollback 只能回到已记录版本,并写入 audit。 +6. local、remote、market、GitHub release、official 路径共享相同生命周期语义。 +7. GUI 插件详情能解释当前状态、来源、信任、版本、回滚和隔离原因。 +8. 所有生命周期状态转换都有测试和 audit。 +9. 文档明确 0.62.2 不是完整 marketplace,也不是新插件 runtime/API 版本。 + +## Release Boundary + +0.62.2 的完成定义是“插件生命周期可解释、可测试、可恢复”。它不追求更多 hook,不增加 Provider 插件能力,也不改变 API v1。下一步如果要继续做插件生态,建议在 0.63.0 再规划 Gateway 插件能力增强;如果要做插件市场,则应在生命周期稳定之后单独规划轻量 market/source 管理。 From 1b7384da6c7846b24c7bc2deeeb657512413f4ce Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 20:20:47 +0800 Subject: [PATCH 062/244] docs(plugins): plan 0.62.2 lifecycle implementation --- ...-aio-coding-hub-0-62-2-plugin-lifecycle.md | 2092 +++++++++++++++++ 1 file changed, 2092 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-aio-coding-hub-0-62-2-plugin-lifecycle.md diff --git a/docs/superpowers/plans/2026-06-23-aio-coding-hub-0-62-2-plugin-lifecycle.md b/docs/superpowers/plans/2026-06-23-aio-coding-hub-0-62-2-plugin-lifecycle.md new file mode 100644 index 00000000..5425fc09 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-aio-coding-hub-0-62-2-plugin-lifecycle.md @@ -0,0 +1,2092 @@ +# aio-coding-hub 0.62.2 Plugin Lifecycle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the 0.62.2 plugin distribution and lifecycle release: install preview, update diff, lifecycle state explanation, rollback/quarantine visibility, and tests, without changing Plugin API v1. + +**Architecture:** Keep Plugin API v1 stable and add a host-owned lifecycle explanation layer around the existing install/update/rollback/quarantine service. Rust remains the source of truth for package inspection, compatibility, trust, and lifecycle diffs; Tauri commands expose preview/diff models; the React UI renders those models and stops inferring lifecycle state from scattered audit details. + +**Tech Stack:** Rust, Tauri 2 commands, Specta generated TypeScript bindings, React 19, TanStack Query, Vitest, Cargo tests, Markdown docs. + +--- + +## Scope Boundaries + +- Do not change public `plugin.json` v1 shape. +- Do not add Plugin API v2. +- Do not expose Provider Plugin API. +- Do not enable JS, TypeScript, WebView, or arbitrary marketplace WASM runtime. +- Do not build a full marketplace page, plugin source CRUD, rating/review system, or automatic background updater. +- Preview/diff is an explanation layer only. Install/update must re-run package extraction, compatibility, checksum, signature, runtime, and permission policy checks. + +## File Structure + +- Modify: `src-tauri/src/domain/plugins.rs` + - Add lifecycle DTOs exported through Specta. + - Keep these types data-only; do not put filesystem or DB logic here. +- Modify: `src-tauri/src/infra/plugins/package.rs` + - Add an inspection extraction path that still enforces archive safety but lets preview report manifest compatibility blockers instead of failing before a preview can be built. +- Modify: `src-tauri/src/app/plugin_service.rs` + - Add local package install preview and update diff service functions. + - Reuse existing package extraction, manifest validation, trust policy, compatibility, permission risk, and repository helpers. + - Keep install/update state mutation in existing install/update functions. +- Modify: `src-tauri/src/commands/plugins.rs` + - Add Tauri commands for local install preview and local update preview. + - Keep remote install restrictions and existing install/update commands unchanged. +- Modify: `src-tauri/src/commands/registry.rs` + - Register new plugin preview commands for runtime and Specta export. +- Modify: `src/generated/bindings.ts` + - Regenerate through `pnpm tauri:gen-types`; do not hand-edit generated output. +- Modify: `src/services/plugins.ts` + - Add IPC wrappers and type exports for preview/diff. +- Modify: `src/query/keys.ts` + - Add query keys for local install preview and local update preview. +- Modify: `src/query/plugins.ts` + - Add preview/diff query helpers or mutations for selected file paths. +- Create: `src/pages/plugins/PluginInstallPreviewDialog.tsx` + - Render install preview and confirmation controls. +- Create: `src/pages/plugins/PluginUpdatePreviewDialog.tsx` + - Render update diff and confirmation controls. +- Create: `src/pages/plugins/PluginLifecyclePanel.tsx` + - Render status/source/trust/version/quarantine/rollback summary inside plugin detail. +- Modify: `src/pages/PluginsPage.tsx` + - Wire file selection -> preview dialog -> confirm install/update. + - Use `PluginLifecyclePanel` in the detail panel. +- Modify: `src/pages/__tests__/PluginsPage.test.tsx` + - Cover preview, update diff, quarantine, rollback, and pending permission UI. +- Modify: `docs/plugins/developer-guide.md` + - Document lifecycle preview and update diff in the install/update workflow. +- Modify: `docs/plugins/reference/publishing.md` + - Document package trust, signature/checksum, update permission delta, rollback, and quarantine behavior. +- Modify: `docs/plugins/reference/compatibility.md` + - Document 0.62.2 compatibility and lifecycle boundary. + +## Task 1: Add Lifecycle DTOs And Rust Failing Tests + +**Files:** + +- Modify: `src-tauri/src/domain/plugins.rs` +- Modify: `src-tauri/src/infra/plugins/package.rs` +- Modify: `src-tauri/src/app/plugin_service.rs` + +- [ ] **Step 1: Add failing service tests for install preview** + +Add this test inside `#[cfg(test)] mod tests` in `src-tauri/src/app/plugin_service.rs` near the local package tests: + +```rust +#[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 { + allow_unsigned: true, + 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, "declarativeRules"); + assert!(preview.runtime.supported); + assert!(preview.compatibility.compatible); + assert!(preview.trust.unsigned); + assert!(!preview.trust.signature_verified); + assert_eq!(preview.permissions[0].permission, "request.meta.read"); + assert_eq!(preview.permissions[0].risk, PluginPermissionRisk::Low); + assert!(preview.blocking_reasons.is_empty()); + assert!(repository::get_plugin(&db, "local.preview-safe").is_err()); +} +``` + +- [ ] **Step 2: Add failing service test for incompatible install preview** + +Add this test in the same test module: + +```rust +#[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 { + allow_unsigned: true, + 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()); +} +``` + +- [ ] **Step 3: Add failing service tests for update diff** + +Add this test in the same test module: + +```rust +#[test] +fn plugin_local_update_preview_reports_permission_runtime_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 { + allow_unsigned: true, + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + grant_plugin_permissions(&db, "local.diff", vec!["request.meta.read".to_string()]).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["hooks"] = serde_json::json!([ + { + "name": "gateway.request.afterBodyRead", + "priority": 10, + "failurePolicy": "fail-open" + }, + { + "name": "gateway.request.beforeSend", + "priority": 20, + "failurePolicy": "fail-open" + } + ]); + v2_manifest["permissions"] = + serde_json::json!(["request.meta.read", "request.header.read"]); + write_local_package(&v2_package, v2_manifest); + + let diff = preview_plugin_update_from_local_package( + &db, + &v2_package, + &cache_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + allow_unsigned: true, + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + + 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.iter().any(|change| { + change.permission == "request.meta.read" && change.change == "unchanged_granted" + })); + assert!(diff.permission_changes.iter().any(|change| { + change.permission == "request.header.read" && change.change == "added_pending" + })); + assert!(diff.blocking_reasons.is_empty()); +} +``` + +- [ ] **Step 4: Run the failing Rust tests** + +Run: + +```bash +cd src-tauri && cargo test plugin_local_install_preview_reports_identity_risk_and_trust_without_db_mutation --lib +cd src-tauri && cargo test plugin_local_install_preview_reports_incompatible_manifest_without_installing --lib +cd src-tauri && cargo test plugin_local_update_preview_reports_permission_runtime_hook_and_config_changes --lib +``` + +Expected: all three fail because `preview_plugin_from_local_package_with_policy`, `preview_plugin_update_from_local_package`, the inspection extractor, and lifecycle DTO fields do not exist. + +- [ ] **Step 5: Add lifecycle DTOs** + +In `src-tauri/src/domain/plugins.rs`, add these data-only types after `PluginRuntimeFailure`: + +```rust +#[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, +} + +#[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)] +#[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 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)] +#[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 config_version_change: Option, + pub compatibility: PluginCompatibilitySummary, + pub trust: PluginTrustSummary, + pub rollback_available: bool, + pub blocking_reasons: Vec, + pub warnings: Vec, +} +``` + +- [ ] **Step 6: Keep the red tests uncommitted until implementation** + +Do not commit after this step. The tests intentionally fail until Task 2 implements package inspection and lifecycle builders. Keep the working tree changes in place and continue directly to Task 2. + +Expected working tree paths: + +```bash +src-tauri/src/domain/plugins.rs +src-tauri/src/app/plugin_service.rs +``` + +Expected: no commit yet. + +## Task 2: Implement Preview And Update Diff Service + +**Files:** + +- Modify: `src-tauri/src/infra/plugins/package.rs` +- Modify: `src-tauri/src/app/plugin_service.rs` + +- [ ] **Step 1: Import lifecycle DTOs** + +Update the existing `use crate::domain::plugins::{ ... }` import at the top of `src-tauri/src/app/plugin_service.rs` to include: + +```rust +PluginCompatibilitySummary, PluginHookLifecycleSummary, PluginInstallPreview, +PluginLifecycleChange, PluginLifecycleNotice, PluginPermissionLifecycleChange, +PluginPermissionLifecycleSummary, PluginRuntimeLifecycleSummary, PluginTrustSummary, +PluginUpdateDiff, +``` + +- [ ] **Step 2: Add package inspection extraction** + +In `src-tauri/src/infra/plugins/package.rs`, refactor package extraction so the existing install path still validates the manifest against the host, while preview can inspect incompatible manifests and report compatibility blockers. + +Replace the end of `extract_plugin_package`: + +```rust +match extract_zip_bytes(bytes, staging_dir, &limits, checksum) { + Ok(extracted) => Ok(extracted), + Err(error) => { + let _ = std::fs::remove_dir_all(staging_dir); + Err(error) + } +} +``` + +with: + +```rust +match extract_zip_bytes(bytes, staging_dir, &limits, checksum, true) { + Ok(extracted) => Ok(extracted), + Err(error) => { + let _ = std::fs::remove_dir_all(staging_dir); + Err(error) + } +} +``` + +Change the `extract_zip_bytes` signature to: + +```rust +fn extract_zip_bytes( + bytes: Vec, + staging_dir: &Path, + limits: &PluginPackageLimits, + checksum: String, + validate_manifest_for_host: bool, +) -> AppResult { +``` + +Then guard the existing host validation at the end of `extract_zip_bytes`: + +```rust +if validate_manifest_for_host { + crate::domain::plugins::validate_manifest(&manifest, env!("CARGO_PKG_VERSION"))?; +} +``` + +Finally add this public inspection function next to `extract_plugin_package`: + +```rust +pub(crate) fn extract_plugin_package_for_inspection( + package_path: &Path, + staging_dir: &Path, + limits: PluginPackageLimits, +) -> AppResult { + let metadata = std::fs::metadata(package_path).map_err(|error| { + AppError::new( + "PLUGIN_PACKAGE_NOT_FOUND", + format!("failed to read plugin package metadata: {error}"), + ) + })?; + if metadata.len() > limits.max_package_bytes { + return Err(AppError::new( + "PLUGIN_PACKAGE_TOO_LARGE", + format!( + "plugin package exceeds {} bytes: {}", + limits.max_package_bytes, + package_path.display() + ), + )); + } + + let bytes = std::fs::read(package_path).map_err(|error| { + AppError::new( + "PLUGIN_PACKAGE_READ_FAILED", + format!("failed to read plugin package: {error}"), + ) + })?; + if bytes.len() as u64 > limits.max_package_bytes { + return Err(AppError::new( + "PLUGIN_PACKAGE_TOO_LARGE", + format!( + "plugin package exceeds {} bytes: {}", + limits.max_package_bytes, + package_path.display() + ), + )); + } + + 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() + ), + ) + })?; + } + std::fs::create_dir_all(staging_dir).map_err(|error| { + AppError::new( + "PLUGIN_PACKAGE_STAGING_FAILED", + format!( + "failed to create staging dir {}: {error}", + staging_dir.display() + ), + ) + })?; + + let checksum = format!("sha256:{:x}", Sha256::digest(&bytes)); + match extract_zip_bytes(bytes, staging_dir, &limits, checksum, false) { + Ok(extracted) => Ok(extracted), + Err(error) => { + let _ = std::fs::remove_dir_all(staging_dir); + Err(error) + } + } +} +``` + +The new inspection path must still reject invalid archives, unsafe paths, missing `plugin.json`, invalid JSON, and manifest shapes that cannot deserialize. It must only skip the final host compatibility/runtime/permission validation so `PluginInstallPreview.compatibility` and runtime blockers can explain those failures. + +- [ ] **Step 3: Add package inspection helpers** + +Add these helpers near `install_plugin_from_local_package_with_policy`: + +```rust +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 compare_version_direction(from: &str, to: &str) -> String { + fn parse(version: &str) -> Option<(u64, u64, u64)> { + let core = version.split_once('-').map_or(version, |(core, _)| core); + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) + } + + match (parse(from), parse(to)) { + (Some(left), Some(right)) if right > left => "upgrade".to_string(), + (Some(left), Some(right)) if right < left => "downgrade".to_string(), + (Some(_), Some(_)) => "same".to_string(), + _ => "unknown".to_string(), + } +} +``` + +- [ ] **Step 4: Add summary builders** + +Add these helpers in `src-tauri/src/app/plugin_service.rs`: + +```rust +fn runtime_lifecycle_summary(manifest: &PluginManifest) -> PluginRuntimeLifecycleSummary { + match &manifest.runtime { + PluginRuntime::DeclarativeRules { .. } => PluginRuntimeLifecycleSummary { + kind: "declarativeRules".to_string(), + label: "Declarative Rules".to_string(), + supported: true, + blocking_reasons: Vec::new(), + }, + PluginRuntime::Native { engine } if manifest.id == OFFICIAL_PRIVACY_FILTER_ID => { + PluginRuntimeLifecycleSummary { + kind: "native".to_string(), + label: format!("Native ({engine})"), + supported: true, + blocking_reasons: Vec::new(), + } + } + PluginRuntime::Native { engine } => PluginRuntimeLifecycleSummary { + kind: "native".to_string(), + label: format!("Native ({engine})"), + supported: false, + blocking_reasons: vec![lifecycle_notice( + "error", + "PLUGIN_NATIVE_RUNTIME_UNSUPPORTED", + "third-party native plugin runtime is not supported", + )], + }, + PluginRuntime::Wasm { .. } => PluginRuntimeLifecycleSummary { + kind: "wasm".to_string(), + label: "WASM".to_string(), + supported: false, + blocking_reasons: vec![lifecycle_notice( + "warn", + "PLUGIN_WASM_POLICY_GATED", + "WASM plugin execution is policy-gated in this release", + )], + }, + } +} + +fn hook_lifecycle_summaries(manifest: &PluginManifest) -> Vec { + manifest + .hooks + .iter() + .map(|hook| PluginHookLifecycleSummary { + name: hook.name.clone(), + priority: hook.priority, + failure_policy: hook.failure_policy.clone(), + }) + .collect() +} + +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 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 { + PluginTrustSummary { + checksum: extracted.checksum.clone(), + expected_checksum: policy.expected_checksum.clone(), + checksum_verified: policy.expected_checksum.is_some(), + signature_verified: trust.signature_verified, + unsigned: !trust.signature_verified, + developer_mode: policy.developer_mode, + } +} +``` + +- [ ] **Step 5: Implement install preview** + +Add this exported service function near `install_plugin_from_local_package_with_policy`: + +```rust +pub(crate) fn preview_plugin_from_local_package_with_policy( + 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 = + staging_root.join(format!("preview-{}", crate::shared::time::now_unix_seconds())); + let extracted = package::extract_plugin_package_for_inspection( + package_path, + &staging_dir, + package::PluginPackageLimits::default(), + )?; + 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 mut blocking_reasons = Vec::new(); + let mut warnings = Vec::new(); + let compatibility = compatibility_summary(manifest, host_version); + blocking_reasons.extend(compatibility.blocking_reasons.clone()); + let runtime = runtime_lifecycle_summary(manifest); + 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) => { + blocking_reasons.push(lifecycle_notice( + "error", + &app_error_code(&error), + 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", + &app_error_code(&error), + 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()); + + 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(&manifest.permissions, &[], &manifest.permissions), + compatibility, + trust: trust_summary(extracted, policy, trust), + existing_status, + existing_version, + blocking_reasons, + warnings, + }) +} +``` + +`AppError` exposes `code()` but not a public message accessor, so add these local helpers and use them for lifecycle notices: + +```rust +fn app_error_code(error: &AppError) -> String { + error.to_string().split_once(':').map_or_else( + || "PLUGIN_LIFECYCLE_ERROR".to_string(), + |(code, _)| code.to_string(), + ) +} + +fn app_error_message(error: &AppError) -> String { + error.to_string().split_once(':').map_or_else( + || error.to_string(), + |(_, message)| message.trim().to_string(), + ) +} +``` + +- [ ] **Step 6: Implement update diff** + +Add these helpers below the install preview helpers: + +```rust +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 = + staging_root.join(format!("update-preview-{}", crate::shared::time::now_unix_seconds())); + let extracted = package::extract_plugin_package_for_inspection( + package_path, + &staging_dir, + package::PluginPackageLimits::default(), + )?; + 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", + &app_error_code(&error), + 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", + &app_error_code(&error), + app_error_message(&error), + )); + } + + let current_runtime = runtime_lifecycle_summary(¤t.manifest); + let next_runtime = runtime_lifecycle_summary(manifest); + let runtime_change = (current_runtime.kind != next_runtime.kind + || current_runtime.label != next_runtime.label) + .then(|| PluginLifecycleChange { + name: "runtime".to_string(), + change: "changed".to_string(), + before: Some(current_runtime.label), + after: Some(next_runtime.label), + }); + + let hook_changes = diff_hooks(¤t.manifest, manifest); + let permission_changes = diff_permissions(¤t, manifest); + let config_version_change = config_version_change(¤t.manifest, manifest); + let rollback_available = current + .summary + .current_version + .as_deref() + .is_some_and(|version| repository::get_plugin_version(db, &manifest.id, version).is_ok()); + + if compare_version_direction( + current.summary.current_version.as_deref().unwrap_or(¤t.manifest.version), + &manifest.version, + ) == "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: current + .summary + .current_version + .clone() + .unwrap_or_else(|| current.manifest.version.clone()), + to_version: manifest.version.clone(), + version_direction: compare_version_direction( + current.summary.current_version.as_deref().unwrap_or(¤t.manifest.version), + &manifest.version, + ), + runtime_change, + hook_changes, + permission_changes, + config_version_change, + compatibility, + trust: trust_summary(extracted, policy, trust), + rollback_available, + blocking_reasons, + warnings, + }) +} +``` + +Add these diff helpers: + +```rust +fn diff_hooks(before: &PluginManifest, after: &PluginManifest) -> Vec { + let mut changes = Vec::new(); + 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 mut all = current.manifest.permissions.clone(); + for permission in &next.permissions { + if !all.contains(permission) { + all.push(permission.clone()); + } + } + all.sort(); + + all.into_iter() + .map(|permission| { + let was_requested = current.manifest.permissions.contains(&permission); + let is_requested = next.permissions.contains(&permission); + let was_granted = current.granted_permissions.contains(&permission); + let was_pending = current.pending_permissions.contains(&permission); + let change = match (was_requested, is_requested, was_granted, was_pending) { + (true, true, true, _) => "unchanged_granted", + (true, true, false, true) => "unchanged_pending", + (true, true, false, false) => "unchanged_requested", + (false, true, _, _) => "added_pending", + (true, false, _, _) => "removed", + (false, false, _, _) => "not_requested", + }; + PluginPermissionLifecycleChange { + risk: permission_risk(&permission).unwrap_or(PluginPermissionRisk::Low), + permission, + change: change.to_string(), + } + }) + .filter(|change| change.change != "not_requested") + .collect() +} + +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}")) +} +``` + +- [ ] **Step 7: Run focused Rust tests** + +Run: + +```bash +cd src-tauri && cargo test plugin_local_install_preview_reports_identity_risk_and_trust_without_db_mutation --lib +cd src-tauri && cargo test plugin_local_install_preview_reports_incompatible_manifest_without_installing --lib +cd src-tauri && cargo test plugin_local_update_preview_reports_permission_runtime_hook_and_config_changes --lib +``` + +Expected: all three pass. + +- [ ] **Step 8: Run existing lifecycle regression tests** + +Run: + +```bash +cd src-tauri && cargo test plugin_update_rollback --lib +cd src-tauri && cargo test plugin_market_revoked_quarantines_installed_plugin --lib +cd src-tauri && cargo test plugin_remote_install --lib +``` + +Expected: all pass. + +- [ ] **Step 9: Commit lifecycle DTOs, tests, and service implementation** + +Run: + +```bash +git add src-tauri/src/domain/plugins.rs src-tauri/src/infra/plugins/package.rs src-tauri/src/app/plugin_service.rs +git commit -m "feat(plugins): add lifecycle preview and update diff" +``` + +Expected: commit succeeds. + +## Task 3: Expose Preview/Diff Through Tauri Commands And Bindings + +**Files:** + +- Modify: `src-tauri/src/commands/plugins.rs` +- Modify: `src-tauri/src/commands/registry.rs` +- Modify: `src/generated/bindings.ts` +- Modify: `src/services/plugins.ts` + +- [ ] **Step 1: Add failing registry test entries** + +In `src-tauri/src/commands/registry.rs`, update `includes_plugin_commands_in_generated_command_registry` to include: + +```rust +"plugin_preview_from_file", +"plugin_preview_update_from_file", +``` + +Run: + +```bash +cd src-tauri && cargo test includes_plugin_commands_in_generated_command_registry --lib +``` + +Expected: FAIL because the commands are not registered. + +- [ ] **Step 2: Add command input types** + +In `src-tauri/src/commands/plugins.rs`, add after `PluginInstallFromFileInput`: + +```rust +#[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, +} +``` + +Update the imports at the top of the file: + +```rust +use crate::domain::plugins::{ + PluginAuditLog, PluginDetail, PluginInstallPreview, PluginInstallSource, PluginUpdateDiff, +}; +``` + +- [ ] **Step 3: Add preview commands** + +In `src-tauri/src/commands/plugins.rs`, add these commands before `plugin_install_from_file`: + +```rust +#[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"), + plugin_service::LocalPackageInstallPolicy { + allow_unsigned: true, + developer_mode: true, + ..plugin_service::LocalPackageInstallPolicy::default() + }, + ) + }) + .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"), + plugin_service::LocalPackageInstallPolicy { + allow_unsigned: true, + developer_mode: true, + ..plugin_service::LocalPackageInstallPolicy::default() + }, + ) + }) + .await + .map_err(Into::into) +} +``` + +- [ ] **Step 4: Register commands** + +In `src-tauri/src/commands/registry.rs`, add these entries in the plugin command block before install/update: + +```rust +plugin_preview_from_file => crate::commands::plugins::plugin_preview_from_file, +plugin_preview_update_from_file => crate::commands::plugins::plugin_preview_update_from_file, +``` + +- [ ] **Step 5: Run command registry test** + +Run: + +```bash +cd src-tauri && cargo test includes_plugin_commands_in_generated_command_registry --lib +``` + +Expected: PASS. + +- [ ] **Step 6: Regenerate TypeScript bindings** + +Run: + +```bash +pnpm tauri:gen-types +pnpm check:generated-bindings +``` + +Expected: both pass and `src/generated/bindings.ts` contains `pluginPreviewFromFile`, `pluginPreviewUpdateFromFile`, `PluginInstallPreview`, and `PluginUpdateDiff`. + +- [ ] **Step 7: Add frontend IPC wrappers** + +In `src/services/plugins.ts`, update the generated type import/export list to include: + +```ts +type PluginInstallPreview, +type PluginUpdateDiff, +``` + +Add these exported functions after `pluginGet`: + +```ts +export async function pluginPreviewFromFile(filePath: string) { + const normalizedFilePath = normalizePluginFilePath(filePath); + + return invokeGeneratedIpc({ + title: "预检插件失败", + cmd: "plugin_preview_from_file", + args: { filePath: normalizedFilePath }, + invoke: async () => commands.pluginPreviewFromFile({ filePath: normalizedFilePath }), + }); +} + +export async function pluginPreviewUpdateFromFile(filePath: string) { + const normalizedFilePath = normalizePluginFilePath(filePath); + + return invokeGeneratedIpc({ + title: "预检插件更新失败", + cmd: "plugin_preview_update_from_file", + args: { filePath: normalizedFilePath }, + invoke: async () => commands.pluginPreviewUpdateFromFile({ filePath: normalizedFilePath }), + }); +} +``` + +- [ ] **Step 8: Run focused typecheck** + +Run: + +```bash +pnpm typecheck +``` + +Expected: PASS. + +- [ ] **Step 9: Commit command and bindings work** + +Run: + +```bash +git add src-tauri/src/commands/plugins.rs src-tauri/src/commands/registry.rs src/generated/bindings.ts src/services/plugins.ts +git commit -m "feat(plugins): expose lifecycle preview commands" +``` + +Expected: commit succeeds. + +## Task 4: Add Frontend Query Hooks And Preview Dialog Components + +**Files:** + +- Modify: `src/query/keys.ts` +- Modify: `src/query/plugins.ts` +- Create: `src/pages/plugins/PluginInstallPreviewDialog.tsx` +- Create: `src/pages/plugins/PluginUpdatePreviewDialog.tsx` +- Create: `src/pages/plugins/PluginLifecyclePanel.tsx` +- Modify: `src/pages/__tests__/PluginsPage.test.tsx` + +- [ ] **Step 1: Add failing frontend tests for preview and diff UI** + +In `src/pages/__tests__/PluginsPage.test.tsx`, extend the query mock import to include: + +```ts +usePluginPreviewFromFileMutation, +usePluginPreviewUpdateFromFileMutation, +``` + +Add these to the `vi.mock("../query/plugins", ...)` return object: + +```ts +usePluginPreviewFromFileMutation: vi.fn(), +usePluginPreviewUpdateFromFileMutation: vi.fn(), +``` + +Add default mocks in the test setup: + +```ts +vi.mocked(usePluginPreviewFromFileMutation).mockReturnValue(mutation() as any); +vi.mocked(usePluginPreviewUpdateFromFileMutation).mockReturnValue(mutation() as any); +``` + +Add this test: + +```ts +it("previews a local plugin package before installing it", async () => { + const previewMutation = mutation({ + mutateAsync: vi.fn().mockResolvedValue({ + pluginId: "local.preview-safe", + name: "Local Package", + version: "1.0.0", + source: "local", + description: "Preview package", + author: null, + homepage: null, + repository: null, + license: "MIT", + category: "gateway", + runtime: { + kind: "declarativeRules", + label: "Declarative Rules", + supported: true, + blockingReasons: [], + }, + hooks: [{ name: "gateway.request.afterBodyRead", priority: 10, failurePolicy: "fail-open" }], + permissions: [{ permission: "request.meta.read", risk: "low", granted: false, pending: true }], + compatibility: { + compatible: true, + hostVersion: "0.62.2", + appRange: ">=0.56.0 <1.0.0", + pluginApiRange: "^1.0.0", + platforms: ["macos", "windows", "linux"], + blockingReasons: [], + }, + trust: { + checksum: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + expectedChecksum: null, + checksumVerified: false, + signatureVerified: false, + unsigned: true, + developerMode: true, + }, + existingStatus: null, + existingVersion: null, + blockingReasons: [], + warnings: [], + }), + }); + const installMutation = mutation(); + vi.mocked(usePluginPreviewFromFileMutation).mockReturnValue(previewMutation as any); + vi.mocked(usePluginInstallFromFileMutation).mockReturnValue(installMutation as any); + vi.mocked(openDesktopSinglePath).mockResolvedValue("/tmp/local-preview.aio-plugin"); + + renderWithProviders(); + fireEvent.click(screen.getByRole("button", { name: "导入 .aio-plugin" })); + + expect(await screen.findByText("安装前预检")).toBeInTheDocument(); + expect(screen.getByText("local.preview-safe")).toBeInTheDocument(); + expect(screen.getByText("Declarative Rules")).toBeInTheDocument(); + expect(screen.getByText("未签名")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "确认安装" })); + + await waitFor(() => { + expect(previewMutation.mutateAsync).toHaveBeenCalledWith("/tmp/local-preview.aio-plugin"); + expect(installMutation.mutateAsync).toHaveBeenCalledWith("/tmp/local-preview.aio-plugin"); + }); +}); +``` + +Add this test: + +```ts +it("shows update diff before applying a local plugin update", async () => { + const updatePreviewMutation = mutation({ + mutateAsync: vi.fn().mockResolvedValue({ + pluginId: "community.redactor", + fromVersion: "1.0.0", + toVersion: "1.1.0", + versionDirection: "upgrade", + runtimeChange: null, + hookChanges: [ + { + name: "gateway.request.beforeSend", + change: "added", + before: null, + after: "priority=20, failurePolicy=fail-open", + }, + ], + permissionChanges: [ + { permission: "request.body.read", risk: "high", change: "unchanged_granted" }, + { permission: "request.header.read", risk: "medium", change: "added_pending" }, + ], + configVersionChange: "1 -> 2", + compatibility: { + compatible: true, + hostVersion: "0.62.2", + appRange: ">=0.56.0 <1.0.0", + pluginApiRange: "^1.0.0", + platforms: ["macos", "windows", "linux"], + blockingReasons: [], + }, + trust: { + checksum: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + expectedChecksum: null, + checksumVerified: false, + signatureVerified: false, + unsigned: true, + developerMode: true, + }, + rollbackAvailable: true, + blockingReasons: [], + warnings: [], + }), + }); + const updateMutation = mutation(); + vi.mocked(usePluginPreviewUpdateFromFileMutation).mockReturnValue(updatePreviewMutation as any); + vi.mocked(usePluginUpdateFromFileMutation).mockReturnValue(updateMutation as any); + vi.mocked(openDesktopSinglePath).mockResolvedValue("/tmp/community-redactor-1.1.0.aio-plugin"); + + renderWithProviders(); + fireEvent.click(screen.getByRole("button", { name: "更新" })); + + expect(await screen.findByText("更新影响预览")).toBeInTheDocument(); + expect(screen.getByText("1.0.0 -> 1.1.0")).toBeInTheDocument(); + expect(screen.getByText("request.header.read")).toBeInTheDocument(); + expect(screen.getByText("新增待授权")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "确认更新" })); + + await waitFor(() => { + expect(updatePreviewMutation.mutateAsync).toHaveBeenCalledWith( + "/tmp/community-redactor-1.1.0.aio-plugin" + ); + expect(updateMutation.mutateAsync).toHaveBeenCalledWith( + "/tmp/community-redactor-1.1.0.aio-plugin" + ); + }); +}); +``` + +- [ ] **Step 2: Run frontend tests to verify failure** + +Run: + +```bash +pnpm test:unit -- src/pages/__tests__/PluginsPage.test.tsx +``` + +Expected: FAIL because preview hooks and dialogs do not exist. + +- [ ] **Step 3: Add query keys** + +In `src/query/keys.ts`, update `pluginKeys`: + +```ts +const pluginsAllKey = ["plugins"] as const; +export const pluginKeys = { + all: pluginsAllKey, + list: () => [...pluginsAllKey, "list"] as const, + detail: (pluginId: string | null) => [...pluginsAllKey, "detail", pluginId] as const, + auditLogs: (pluginId: string | null, limit: number | null) => + [...pluginsAllKey, "auditLogs", pluginId, limit] as const, + installPreview: (filePath: string | null) => + [...pluginsAllKey, "installPreview", filePath] as const, + updatePreview: (filePath: string | null) => + [...pluginsAllKey, "updatePreview", filePath] as const, +}; +``` + +- [ ] **Step 4: Add query mutations** + +In `src/query/plugins.ts`, import the new service functions: + +```ts +pluginPreviewFromFile, +pluginPreviewUpdateFromFile, +type PluginInstallPreview, +type PluginUpdateDiff, +``` + +Add these hooks after `usePluginQuery`: + +```ts +export function usePluginPreviewFromFileMutation() { + return useMutation({ + mutationFn: (filePath) => pluginPreviewFromFile(filePath), + }); +} + +export function usePluginPreviewUpdateFromFileMutation() { + return useMutation({ + mutationFn: (filePath) => pluginPreviewUpdateFromFile(filePath), + }); +} +``` + +- [ ] **Step 5: Create install preview dialog** + +Create `src/pages/plugins/PluginInstallPreviewDialog.tsx`: + +```tsx +import { AlertTriangle, CheckCircle2, ShieldAlert } from "lucide-react"; +import { Button } from "../../ui/Button"; +import { Dialog } from "../../ui/Dialog"; +import type { PluginInstallPreview } from "../../services/plugins"; +import { pluginRiskLabel } from "./pluginProductCopy"; + +type Props = { + open: boolean; + preview: PluginInstallPreview | null; + busy: boolean; + onClose: () => void; + onConfirm: () => void; +}; + +function noticeTone(severity: string) { + return severity === "error" ? "text-destructive" : "text-warning"; +} + +export function PluginInstallPreviewDialog({ open, preview, busy, onClose, onConfirm }: Props) { + const blocked = (preview?.blockingReasons.length ?? 0) > 0; + + return ( + !next && onClose()}> + {preview ? ( +
+
+
{preview.name}
+
{preview.pluginId}
+
版本 {preview.version}
+
+ +
+
+
Runtime
+
+ {preview.runtime.supported ? : } + {preview.runtime.label} +
+
+
+
Trust
+
+ {preview.trust.signatureVerified ? "签名已验证" : "未签名"} +
+
+
+ +
+
Permissions
+
+ {preview.permissions.map((permission) => ( +
+ {permission.permission} + + {pluginRiskLabel(permission.risk)} + +
+ ))} +
+
+ + {[...preview.blockingReasons, ...preview.warnings].map((notice) => ( +
+ +
+
{notice.code}
+
{notice.message}
+
+
+ ))} + +
+ + +
+
+ ) : null} +
+ ); +} +``` + +- [ ] **Step 6: Create update preview dialog** + +Create `src/pages/plugins/PluginUpdatePreviewDialog.tsx`: + +```tsx +import { AlertTriangle } from "lucide-react"; +import { Button } from "../../ui/Button"; +import { Dialog } from "../../ui/Dialog"; +import type { PluginUpdateDiff } from "../../services/plugins"; +import { pluginRiskLabel } from "./pluginProductCopy"; + +type Props = { + open: boolean; + diff: PluginUpdateDiff | null; + busy: boolean; + onClose: () => void; + onConfirm: () => void; +}; + +function permissionChangeLabel(change: string) { + switch (change) { + case "added_pending": + return "新增待授权"; + case "unchanged_granted": + return "已授权保留"; + case "unchanged_pending": + return "仍待授权"; + case "removed": + return "已移除"; + default: + return "请求不变"; + } +} + +export function PluginUpdatePreviewDialog({ open, diff, busy, onClose, onConfirm }: Props) { + const blocked = (diff?.blockingReasons.length ?? 0) > 0; + + return ( + !next && onClose()}> + {diff ? ( +
+
+
{diff.pluginId}
+
+ {diff.fromVersion} -> {diff.toVersion} +
+ {diff.configVersionChange ? ( +
+ Config {diff.configVersionChange} +
+ ) : null} +
+ +
+
Permission delta
+
+ {diff.permissionChanges.map((permission) => ( +
+ {permission.permission} + {pluginRiskLabel(permission.risk)} + + {permissionChangeLabel(permission.change)} + +
+ ))} +
+
+ + {diff.hookChanges.length > 0 ? ( +
+
Hook changes
+ {diff.hookChanges.map((hook) => ( +
+ {hook.name}: {hook.change} +
+ ))} +
+ ) : null} + + {[...diff.blockingReasons, ...diff.warnings].map((notice) => ( +
+ +
+
{notice.code}
+
{notice.message}
+
+
+ ))} + +
+ + +
+
+ ) : null} +
+ ); +} +``` + +- [ ] **Step 7: Create lifecycle panel** + +Create `src/pages/plugins/PluginLifecyclePanel.tsx`: + +```tsx +import { RotateCcw, ShieldAlert } from "lucide-react"; +import type { PluginDetail } from "../../services/plugins"; +import { Button } from "../../ui/Button"; +import { pluginStatusLabel } from "./pluginProductCopy"; + +type Props = { + detail: PluginDetail; + rollbackVersion: string | null; + busy: boolean; + onRollback: (version: string) => void; +}; + +function auditDetailString(detail: PluginDetail, key: string) { + for (const log of detail.audit_logs) { + const details = log.details; + if (details && typeof details === "object" && !Array.isArray(details)) { + const value = (details as Record)[key]; + if (typeof value === "string" && value.trim()) return value; + } + } + return null; +} + +export function PluginLifecyclePanel({ detail, rollbackVersion, busy, onRollback }: Props) { + const unsigned = detail.audit_logs.some((log) => { + const details = log.details; + return Boolean(details && typeof details === "object" && !Array.isArray(details) && (details as Record).unsigned === true); + }); + const quarantineReason = detail.summary.status === "quarantined" ? detail.summary.last_error : null; + const checksum = auditDetailString(detail, "packageChecksum"); + + return ( +
+

生命周期

+
+
+ 状态 + {pluginStatusLabel(detail.summary.status)} +
+
+ 来源 + {detail.install_source} +
+
+ 信任 + {unsigned ? "未签名" : "签名或官方来源"} +
+ {checksum ? ( +
{checksum}
+ ) : null} + {quarantineReason ? ( +
+ + {quarantineReason} +
+ ) : null} + {rollbackVersion ? ( + + ) : null} +
+
+ ); +} +``` + +- [ ] **Step 8: Run component tests** + +Run: + +```bash +pnpm test:unit -- src/pages/__tests__/PluginsPage.test.tsx +``` + +Expected: still fails because `PluginsPage.tsx` is not wired to these components yet. + +- [ ] **Step 9: Keep frontend red tests uncommitted until page integration** + +Do not commit after this step. The tests intentionally fail until Task 5 wires `PluginsPage.tsx` to the new preview hooks and dialogs. Keep these working tree changes in place: + +```bash +src/query/keys.ts +src/query/plugins.ts +src/pages/plugins/PluginInstallPreviewDialog.tsx +src/pages/plugins/PluginUpdatePreviewDialog.tsx +src/pages/plugins/PluginLifecyclePanel.tsx +src/pages/__tests__/PluginsPage.test.tsx +``` + +Expected: no commit yet. + +## Task 5: Wire PluginsPage Lifecycle Flow + +**Files:** + +- Modify: `src/pages/PluginsPage.tsx` +- Modify: `src/pages/__tests__/PluginsPage.test.tsx` + +- [ ] **Step 1: Import new hooks and components** + +In `src/pages/PluginsPage.tsx`, extend query imports: + +```ts +usePluginPreviewFromFileMutation, +usePluginPreviewUpdateFromFileMutation, +``` + +Add component imports: + +```ts +import { PluginInstallPreviewDialog } from "./plugins/PluginInstallPreviewDialog"; +import { PluginLifecyclePanel } from "./plugins/PluginLifecyclePanel"; +import { PluginUpdatePreviewDialog } from "./plugins/PluginUpdatePreviewDialog"; +``` + +Extend type imports: + +```ts +PluginInstallPreview, +PluginUpdateDiff, +``` + +- [ ] **Step 2: Add preview state** + +Inside `PluginsPage`, add state near existing mutation setup: + +```ts +const previewMutation = usePluginPreviewFromFileMutation(); +const updatePreviewMutation = usePluginPreviewUpdateFromFileMutation(); +const [pendingInstallPath, setPendingInstallPath] = useState(null); +const [installPreview, setInstallPreview] = useState(null); +const [pendingUpdatePath, setPendingUpdatePath] = useState(null); +const [updatePreview, setUpdatePreview] = useState(null); +``` + +Include preview mutations in `busy`: + +```ts +previewMutation.isPending || +updatePreviewMutation.isPending || +``` + +- [ ] **Step 3: Change import action to preview first** + +Replace the current direct install behavior in `handleImportPlugin`: + +```ts +async function handleImportPlugin() { + const filePath = await openDesktopSinglePath({ + title: "选择 .aio-plugin", + filters: [{ name: "AIO Plugin", extensions: ["aio-plugin"] }], + }); + if (!filePath) return; + await runAction("预检插件", async () => { + const preview = await previewMutation.mutateAsync(filePath); + setPendingInstallPath(filePath); + setInstallPreview(preview); + }); +} +``` + +Add confirmation handler: + +```ts +async function confirmInstallPreview() { + if (!pendingInstallPath) return; + await runAction("导入插件", async () => { + await installMutation.mutateAsync(pendingInstallPath); + setPendingInstallPath(null); + setInstallPreview(null); + }); +} +``` + +- [ ] **Step 4: Change update action to preview first** + +Replace direct update behavior: + +```ts +async function handleUpdatePlugin() { + const filePath = await openDesktopSinglePath({ + title: "选择更新包", + filters: [{ name: "AIO Plugin", extensions: ["aio-plugin"] }], + }); + if (!filePath) return; + await runAction("预检插件更新", async () => { + const diff = await updatePreviewMutation.mutateAsync(filePath); + setPendingUpdatePath(filePath); + setUpdatePreview(diff); + }); +} +``` + +Add confirmation handler: + +```ts +async function confirmUpdatePreview() { + if (!pendingUpdatePath) return; + await runAction("更新插件", async () => { + await updateMutation.mutateAsync(pendingUpdatePath); + setPendingUpdatePath(null); + setUpdatePreview(null); + }); +} +``` + +- [ ] **Step 5: Render lifecycle panel** + +In the plugin detail area, replace duplicated lifecycle-ish rows and inline rollback block with: + +```tsx + + runAction("回滚插件", () => + rollbackMutation.mutateAsync({ pluginId: selectedPluginId, version }) + ) + } +/> +``` + +Keep the existing detail rows for identity/manifest facts. The lifecycle panel owns status/source/trust/quarantine/rollback explanation. + +- [ ] **Step 6: Render preview dialogs** + +Near the bottom of `PluginsPage` JSX, render: + +```tsx + { + setInstallPreview(null); + setPendingInstallPath(null); + }} + onConfirm={() => void confirmInstallPreview()} +/> + + { + setUpdatePreview(null); + setPendingUpdatePath(null); + }} + onConfirm={() => void confirmUpdatePreview()} +/> +``` + +- [ ] **Step 7: Run frontend tests** + +Run: + +```bash +pnpm test:unit -- src/pages/__tests__/PluginsPage.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 8: Run frontend typecheck** + +Run: + +```bash +pnpm typecheck +``` + +Expected: PASS. + +- [ ] **Step 9: Commit frontend preview flow** + +Run: + +```bash +git add src/query/keys.ts src/query/plugins.ts src/pages/plugins/PluginInstallPreviewDialog.tsx src/pages/plugins/PluginUpdatePreviewDialog.tsx src/pages/plugins/PluginLifecyclePanel.tsx src/pages/PluginsPage.tsx src/pages/__tests__/PluginsPage.test.tsx +git commit -m "feat(plugins): wire lifecycle preview flow" +``` + +Expected: commit succeeds. + +## Task 6: Docs, Regression Gates, And Final Verification + +**Files:** + +- Modify: `docs/plugins/developer-guide.md` +- Modify: `docs/plugins/reference/publishing.md` +- Modify: `docs/plugins/reference/compatibility.md` + +- [ ] **Step 1: Update developer guide install workflow** + +In `docs/plugins/developer-guide.md`, update the local install paragraph under "10 分钟快速开始" to state: + +```markdown +在 Plugins 页面选择本地包 `acme.redactor.aio-plugin` 后,0.62.2 会先展示安装前预检:插件 id、版本、runtime、hooks、permissions、兼容性、checksum 和签名状态。确认后才会写入插件库。更新插件时,页面会先展示版本变化、Hook 变化、配置版本变化和权限 delta;新增权限会进入待授权列表,不会静默继承授权。 +``` + +- [ ] **Step 2: Update publishing reference** + +In `docs/plugins/reference/publishing.md`, add a section after the current checklist: + +```markdown +## 0.62.2 生命周期预检 + +0.62.2 在安装或更新 `.aio-plugin` 前会生成 host-side preview。Preview 只用于解释风险和变化,不是安全边界;真正安装或更新时宿主仍会重新执行解压、manifest 校验、兼容性校验、checksum、签名和 runtime policy 检查。 + +更新插件时,宿主会比较当前版本和待安装版本: + +- 新增 permissions 进入 pending,不会自动授权。 +- 已授权且仍被请求的 permissions 保持 granted。 +- 已不再请求的 permissions 从新 manifest 中移除。 +- runtime、hooks、configVersion 和 compatibility 变化会展示在更新预览中。 +- rollback 只允许回到数据库中已经记录的历史版本。 +``` + +- [ ] **Step 3: Update compatibility reference** + +In `docs/plugins/reference/compatibility.md`, add: + +```markdown +## 0.62.2 Lifecycle Boundary + +0.62.2 不改变 Plugin API v1。新增的是宿主侧 lifecycle preview 和 update diff:用户可以在安装或更新前看到兼容性、runtime support、permissions、trust summary 和 blocking reasons。 + +Quarantined 或 incompatible 插件不能启用。WASM 仍然 policy-gated。Provider Plugin API 仍然不开放。 +``` + +- [ ] **Step 4: Run docs check** + +Run: + +```bash +pnpm check:plugin-system-docs +``` + +Expected: PASS. + +- [ ] **Step 5: Run Rust plugin tests** + +Run: + +```bash +cd src-tauri && cargo test plugin --lib +``` + +Expected: PASS. + +- [ ] **Step 6: Run frontend plugin tests** + +Run: + +```bash +pnpm test:unit -- src/pages/__tests__/PluginsPage.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 7: Run generated binding and type checks** + +Run: + +```bash +pnpm check:generated-bindings +pnpm typecheck +``` + +Expected: PASS. + +- [ ] **Step 8: Run release gates** + +Run: + +```bash +pnpm check:plugin-api-contract +pnpm check:plugin-system-docs +pnpm check:prepush +``` + +Expected: PASS. + +- [ ] **Step 9: Commit docs and final verification state** + +Run: + +```bash +git add docs/plugins/developer-guide.md docs/plugins/reference/publishing.md docs/plugins/reference/compatibility.md +git commit -m "docs(plugins): document lifecycle preview flow" +``` + +Expected: commit succeeds. + +## Final Review Checklist + +Before marking 0.62.2 implementation complete, verify: + +- [ ] Plugin API v1 manifest shape is unchanged. +- [ ] No Provider Plugin API is exposed. +- [ ] Preview and update diff commands do not mutate the database. +- [ ] Install/update commands still re-run validation and trust policy. +- [ ] New permissions after update are pending, not granted. +- [ ] Quarantined plugins cannot be enabled. +- [ ] GUI import/update flows require preview confirmation before mutation. +- [ ] Generated bindings are in sync. +- [ ] Docs say 0.62.2 is lifecycle stabilization, not a full marketplace. +- [ ] `pnpm check:prepush` passes. From d2117a55edeadecbcb33c7d3e64a55b4b52162de Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 20:53:56 +0800 Subject: [PATCH 063/244] feat(plugins): add lifecycle preview and update diff --- src-tauri/src/app/plugin_service.rs | 664 ++++++++++++++++++++++++- src-tauri/src/domain/plugins.rs | 115 +++++ src-tauri/src/infra/plugins/package.rs | 30 +- 3 files changed, 804 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/app/plugin_service.rs b/src-tauri/src/app/plugin_service.rs index 82ef8cd1..d25b4ba7 100644 --- a/src-tauri/src/app/plugin_service.rs +++ b/src-tauri/src/app/plugin_service.rs @@ -1,6 +1,9 @@ use crate::domain::plugins::{ - permission_risk, validate_manifest, PluginDetail, PluginInstallSource, PluginManifest, - PluginPermissionRisk, PluginRuntime, PluginStatus, + permission_risk, validate_manifest, PluginCompatibilitySummary, PluginDetail, + PluginHookLifecycleSummary, PluginInstallPreview, PluginInstallSource, PluginLifecycleChange, + PluginLifecycleNotice, PluginManifest, PluginPermissionLifecycleChange, + PluginPermissionLifecycleSummary, PluginPermissionRisk, PluginRuntime, + PluginRuntimeLifecycleSummary, PluginStatus, PluginTrustSummary, PluginUpdateDiff, }; use crate::infra::plugins::{package, repository, signing}; use crate::shared::error::{AppError, AppResult}; @@ -171,6 +174,512 @@ struct PackageTrust { signature_verified: bool, } +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 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 { + fn parse(version: &str) -> Option<(u64, u64, u64)> { + let core = version.split_once('-').map_or(version, |(core, _)| core); + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) + } + + match (parse(from), parse(to)) { + (Some(left), Some(right)) if right > left => "upgrade".to_string(), + (Some(left), Some(right)) if right < left => "downgrade".to_string(), + (Some(_), Some(_)) => "same".to_string(), + _ => "unknown".to_string(), + } +} + +fn runtime_lifecycle_summary(manifest: &PluginManifest) -> PluginRuntimeLifecycleSummary { + match &manifest.runtime { + PluginRuntime::DeclarativeRules { .. } => PluginRuntimeLifecycleSummary { + kind: "declarativeRules".to_string(), + label: "Declarative Rules".to_string(), + supported: true, + blocking_reasons: Vec::new(), + }, + PluginRuntime::Native { engine } if manifest.id == OFFICIAL_PRIVACY_FILTER_ID => { + PluginRuntimeLifecycleSummary { + kind: "native".to_string(), + label: format!("Native ({engine})"), + supported: true, + blocking_reasons: Vec::new(), + } + } + PluginRuntime::Native { engine } => PluginRuntimeLifecycleSummary { + kind: "native".to_string(), + label: format!("Native ({engine})"), + supported: false, + blocking_reasons: vec![lifecycle_notice( + "error", + "PLUGIN_NATIVE_RUNTIME_UNSUPPORTED", + "third-party native plugin runtime is not supported", + )], + }, + PluginRuntime::Wasm { .. } => PluginRuntimeLifecycleSummary { + kind: "wasm".to_string(), + label: "WASM".to_string(), + supported: false, + blocking_reasons: vec![lifecycle_notice( + "warn", + "PLUGIN_WASM_POLICY_GATED", + "WASM plugin execution is policy-gated in this release", + )], + }, + } +} + +fn hook_lifecycle_summaries(manifest: &PluginManifest) -> Vec { + manifest + .hooks + .iter() + .map(|hook| PluginHookLifecycleSummary { + name: hook.name.clone(), + priority: hook.priority, + failure_policy: hook.failure_policy.clone(), + }) + .collect() +} + +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 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, + 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 = staging_root.join(format!( + "preview-{}", + crate::shared::time::now_unix_seconds() + )); + 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_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); + 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) => { + 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()); + + 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( + &manifest.permissions, + &[], + &manifest.permissions, + ), + 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 = staging_root.join(format!( + "update-preview-{}", + crate::shared::time::now_unix_seconds() + )); + 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); + let next_runtime = runtime_lifecycle_summary(manifest); + 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), + config_version_change: config_version_change(¤t.manifest, manifest), + compatibility, + trust: trust_summary(extracted, policy, trust), + rollback_available: repository::get_plugin_version(db, &manifest.id, &from_version).is_ok(), + blocking_reasons, + warnings, + }) +} + +fn diff_hooks(before: &PluginManifest, after: &PluginManifest) -> Vec { + let mut changes = Vec::new(); + 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 mut all = current.manifest.permissions.clone(); + for permission in &next.permissions { + if !all.contains(permission) { + all.push(permission.clone()); + } + } + all.sort(); + + all.into_iter() + .map(|permission| { + let was_requested = current.manifest.permissions.contains(&permission); + let is_requested = next.permissions.contains(&permission); + let was_granted = current.granted_permissions.contains(&permission); + let was_pending = current.pending_permissions.contains(&permission); + let change = match (was_requested, is_requested, was_granted, was_pending) { + (true, true, true, _) => "unchanged_granted", + (true, true, false, true) => "unchanged_pending", + (true, true, false, false) => "unchanged_requested", + (false, true, _, _) => "added_pending", + (true, false, _, _) => "removed", + (false, false, _, _) => "not_requested", + }; + let risk = permission_risk(&permission).unwrap_or(PluginPermissionRisk::Low); + PluginPermissionLifecycleChange { + permission, + risk, + change: change.to_string(), + } + }) + .filter(|change| change.change != "not_requested") + .collect() +} + +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, @@ -1156,7 +1665,9 @@ fn validate_enum( #[cfg(test)] mod tests { use super::*; - use crate::domain::plugins::{PluginInstallSource, PluginManifest, PluginStatus}; + use crate::domain::plugins::{ + PluginInstallSource, PluginManifest, PluginPermissionRisk, PluginStatus, + }; use crate::gateway::plugins::context::{GatewayPluginHookName, GatewayRequestHookInput}; use crate::gateway::plugins::pipeline::{GatewayPluginPipeline, GatewayPluginPipelineConfig}; use std::io::Write; @@ -1974,6 +2485,153 @@ DROP TABLE plugins; "sha256:0000000000000000000000000000000000000000000000000000000000000000".to_string() } + #[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 { + allow_unsigned: true, + 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, "declarativeRules"); + assert!(preview.runtime.supported); + assert!(preview.compatibility.compatible); + assert!(preview.trust.unsigned); + assert!(!preview.trust.signature_verified); + assert_eq!(preview.permissions[0].permission, "request.meta.read"); + assert_eq!(preview.permissions[0].risk, PluginPermissionRisk::Low); + 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 { + allow_unsigned: true, + 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_update_preview_reports_permission_runtime_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 { + allow_unsigned: true, + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + grant_plugin_permissions(&db, "local.diff", vec!["request.meta.read".to_string()]).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["hooks"] = serde_json::json!([ + { + "name": "gateway.request.afterBodyRead", + "priority": 10, + "failurePolicy": "fail-open" + }, + { + "name": "gateway.request.beforeSend", + "priority": 20, + "failurePolicy": "fail-open" + } + ]); + v2_manifest["permissions"] = + serde_json::json!(["request.meta.read", "request.header.read"]); + write_local_package(&v2_package, v2_manifest); + + let diff = preview_plugin_update_from_local_package( + &db, + &v2_package, + &cache_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + allow_unsigned: true, + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + + 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.iter().any(|change| { + change.permission == "request.meta.read" && change.change == "unchanged_granted" + })); + assert!(diff.permission_changes.iter().any(|change| { + change.permission == "request.header.read" && change.change == "added_pending" + })); + assert!(diff.blocking_reasons.is_empty()); + } + fn signed_package_policy( package_path: &Path, key_seed: u8, diff --git a/src-tauri/src/domain/plugins.rs b/src-tauri/src/domain/plugins.rs index 5b2e35f4..dcdec60b 100644 --- a/src-tauri/src/domain/plugins.rs +++ b/src-tauri/src/domain/plugins.rs @@ -229,6 +229,121 @@ pub struct PluginRuntimeFailure { 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, +} + +#[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)] +#[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 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)] +#[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 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) diff --git a/src-tauri/src/infra/plugins/package.rs b/src-tauri/src/infra/plugins/package.rs index 3a013e94..c648715b 100644 --- a/src-tauri/src/infra/plugins/package.rs +++ b/src-tauri/src/infra/plugins/package.rs @@ -35,6 +35,23 @@ pub(crate) fn extract_plugin_package( package_path: &Path, staging_dir: &Path, limits: PluginPackageLimits, +) -> AppResult { + extract_plugin_package_with_mode(package_path, staging_dir, limits, true) +} + +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( @@ -92,7 +109,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 +129,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| { @@ -229,7 +253,9 @@ fn extract_zip_bytes( format!("failed to parse plugin package manifest: {error}"), ) })?; - crate::domain::plugins::validate_manifest(&manifest, env!("CARGO_PKG_VERSION"))?; + if validate_manifest_for_host { + crate::domain::plugins::validate_manifest(&manifest, env!("CARGO_PKG_VERSION"))?; + } Ok(ExtractedPluginPackage { root_dir, From c7fbefa9f0e3d3f587a2fe2c5d03aff13de913fb Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 22:17:24 +0800 Subject: [PATCH 064/244] fix plugin lifecycle preview quality issues --- src-tauri/src/app/plugin_service.rs | 349 ++++++++++++++++++++++++++-- 1 file changed, 333 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/app/plugin_service.rs b/src-tauri/src/app/plugin_service.rs index d25b4ba7..318a44d6 100644 --- a/src-tauri/src/app/plugin_service.rs +++ b/src-tauri/src/app/plugin_service.rs @@ -7,6 +7,8 @@ use crate::domain::plugins::{ }; use crate::infra::plugins::{package, repository, signing}; use crate::shared::error::{AppError, AppResult}; +use rusqlite::OptionalExtension; +use std::cmp::Ordering; use std::path::{Path, PathBuf}; const OFFICIAL_PRIVACY_FILTER_ID: &str = "official.privacy-filter"; @@ -199,24 +201,123 @@ fn app_error_message(error: &AppError) -> String { } fn compare_version_direction(from: &str, to: &str) -> String { - fn parse(version: &str) -> Option<(u64, u64, u64)> { - let core = version.split_once('-').map_or(version, |(core, _)| core); - let mut parts = core.split('.'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next()?.parse().ok()?; - let patch = parts.next()?.parse().ok()?; - if parts.next().is_some() { - return None; - } - Some((major, minor, patch)) + 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(), } +} - match (parse(from), parse(to)) { - (Some(left), Some(right)) if right > left => "upgrade".to_string(), - (Some(left), Some(right)) if right < left => "downgrade".to_string(), - (Some(_), Some(_)) => "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) -> PluginRuntimeLifecycleSummary { @@ -583,12 +684,57 @@ fn build_update_diff( config_version_change: config_version_change(¤t.manifest, manifest), compatibility, trust: trust_summary(extracted, policy, trust), - rollback_available: repository::get_plugin_version(db, &manifest.id, &from_version).is_ok(), + 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| Path::new(&installed_dir).is_dir()) +} + +fn rollback_candidate_installed_dir( + db: &crate::db::Db, + plugin_id: &str, + version: &str, +) -> Option { + let conn = db.open_connection().ok()?; + let (current_version_id, current_installed_dir): (i64, Option) = conn + .query_row( + r#" +SELECT id, installed_dir +FROM plugin_versions +WHERE plugin_id = ?1 AND version = ?2 +"#, + rusqlite::params![plugin_id, version], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .ok()??; + + let previous_installed_dir: Option> = conn + .query_row( + r#" +SELECT installed_dir +FROM plugin_versions +WHERE plugin_id = ?1 AND id < ?2 +ORDER BY id DESC +LIMIT 1 +"#, + rusqlite::params![plugin_id, current_version_id], + |row| row.get(0), + ) + .optional() + .ok()?; + + match previous_installed_dir { + Some(installed_dir) => installed_dir, + None => current_installed_dir, + } +} + fn diff_hooks(before: &PluginManifest, after: &PluginManifest) -> Vec { let mut changes = Vec::new(); for hook in &before.hooks { @@ -2632,6 +2778,177 @@ DROP TABLE plugins; assert!(diff.blocking_reasons.is_empty()); } + #[test] + fn plugin_local_update_preview_reports_prerelease_version_direction() { + 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 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, + &rc_package, + &cache_dir, + &installed_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + allow_unsigned: true, + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + + 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, + &release_package, + &cache_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + allow_unsigned: true, + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + + 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")); + + update_plugin_from_local_package( + &db, + &release_package, + &cache_dir, + &installed_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + allow_unsigned: true, + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + + let rc_diff = preview_plugin_update_from_local_package( + &db, + &rc_package, + &cache_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + allow_unsigned: true, + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + + 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(|notice| notice.code == "PLUGIN_UPDATE_DOWNGRADE")); + } + + #[test] + fn plugin_local_update_preview_reports_rollback_unavailable_when_previous_install_dir_is_missing( + ) { + 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("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, + &v1_package, + &cache_dir, + &installed_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + allow_unsigned: true, + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + update_plugin_from_local_package( + &db, + &v2_package, + &cache_dir, + &installed_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + allow_unsigned: true, + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + + let diff_with_previous_dir = preview_plugin_update_from_local_package( + &db, + &v3_package, + &cache_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + allow_unsigned: true, + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + + assert!(diff_with_previous_dir.rollback_available); + + let v1_installed_dir = installed_dir.join("local.rollback-preview").join("1.0.0"); + assert!(v1_installed_dir.is_dir()); + std::fs::remove_dir_all(&v1_installed_dir).unwrap(); + + let diff_without_previous_dir = preview_plugin_update_from_local_package( + &db, + &v3_package, + &cache_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + allow_unsigned: true, + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + + assert!(!diff_without_previous_dir.rollback_available); + } + fn signed_package_policy( package_path: &Path, key_seed: u8, From 41a1e4507b7d3c3c4005c6f0e62f684570398a4f Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 22:23:39 +0800 Subject: [PATCH 065/244] fix(plugins): validate lifecycle rollback target --- src-tauri/src/app/plugin_service.rs | 54 +++++++++-------------------- 1 file changed, 17 insertions(+), 37 deletions(-) diff --git a/src-tauri/src/app/plugin_service.rs b/src-tauri/src/app/plugin_service.rs index 318a44d6..8073056d 100644 --- a/src-tauri/src/app/plugin_service.rs +++ b/src-tauri/src/app/plugin_service.rs @@ -701,38 +701,18 @@ fn rollback_candidate_installed_dir( version: &str, ) -> Option { let conn = db.open_connection().ok()?; - let (current_version_id, current_installed_dir): (i64, Option) = conn - .query_row( - r#" -SELECT id, installed_dir -FROM plugin_versions -WHERE plugin_id = ?1 AND version = ?2 -"#, - rusqlite::params![plugin_id, version], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional() - .ok()??; - - let previous_installed_dir: Option> = conn - .query_row( - r#" + conn.query_row( + r#" SELECT installed_dir FROM plugin_versions -WHERE plugin_id = ?1 AND id < ?2 -ORDER BY id DESC -LIMIT 1 +WHERE plugin_id = ?1 AND version = ?2 "#, - rusqlite::params![plugin_id, current_version_id], - |row| row.get(0), - ) - .optional() - .ok()?; - - match previous_installed_dir { - Some(installed_dir) => installed_dir, - None => current_installed_dir, - } + rusqlite::params![plugin_id, version], + |row| row.get(0), + ) + .optional() + .ok()? + .flatten() } fn diff_hooks(before: &PluginManifest, after: &PluginManifest) -> Vec { @@ -2866,7 +2846,7 @@ DROP TABLE plugins; } #[test] - fn plugin_local_update_preview_reports_rollback_unavailable_when_previous_install_dir_is_missing( + 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("plugins.db")).unwrap(); @@ -2914,7 +2894,7 @@ DROP TABLE plugins; ) .unwrap(); - let diff_with_previous_dir = preview_plugin_update_from_local_package( + let diff_with_current_dir = preview_plugin_update_from_local_package( &db, &v3_package, &cache_dir, @@ -2927,13 +2907,13 @@ DROP TABLE plugins; ) .unwrap(); - assert!(diff_with_previous_dir.rollback_available); + assert!(diff_with_current_dir.rollback_available); - let v1_installed_dir = installed_dir.join("local.rollback-preview").join("1.0.0"); - assert!(v1_installed_dir.is_dir()); - std::fs::remove_dir_all(&v1_installed_dir).unwrap(); + 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(); - let diff_without_previous_dir = preview_plugin_update_from_local_package( + let diff_without_current_dir = preview_plugin_update_from_local_package( &db, &v3_package, &cache_dir, @@ -2946,7 +2926,7 @@ DROP TABLE plugins; ) .unwrap(); - assert!(!diff_without_previous_dir.rollback_available); + assert!(!diff_without_current_dir.rollback_available); } fn signed_package_policy( From 5f820756a7e8d3da3d0ecb557a83331ca4e61515 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 22:35:24 +0800 Subject: [PATCH 066/244] feat: add plugin file preview ipc --- src-tauri/src/commands/plugins.rs | 70 ++++++++++++++++++- src-tauri/src/commands/registry.rs | 4 ++ src/generated/bindings.ts | 106 +++++++++++++++++++++++++++++ src/services/plugins.ts | 26 +++++++ 4 files changed, 205 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs index 852bd13a..0c351507 100644 --- a/src-tauri/src/commands/plugins.rs +++ b/src-tauri/src/commands/plugins.rs @@ -2,7 +2,9 @@ use crate::app::plugin_service; use crate::app_state::{ensure_db_ready, DbInitState}; -use crate::domain::plugins::{PluginAuditLog, PluginDetail, PluginInstallSource}; +use crate::domain::plugins::{ + PluginAuditLog, PluginDetail, PluginInstallPreview, PluginInstallSource, PluginUpdateDiff, +}; use crate::infra::plugins::market::PluginMarketListing; use crate::{blocking, plugins}; use std::collections::HashMap; @@ -21,6 +23,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 { @@ -110,6 +124,14 @@ 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 { + allow_unsigned: true, + developer_mode: true, + ..Default::default() + } +} + #[tauri::command] #[specta::specta] pub(crate) async fn plugin_list( @@ -137,6 +159,52 @@ pub(crate) async fn plugin_get( .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_install_from_file( diff --git a/src-tauri/src/commands/registry.rs b/src-tauri/src/commands/registry.rs index 57f3463e..5af21d5c 100644 --- a/src-tauri/src/commands/registry.rs +++ b/src-tauri/src/commands/registry.rs @@ -166,6 +166,8 @@ macro_rules! generated_command_registry { // ── plugins ── plugin_list => crate::commands::plugins::plugin_list, plugin_get => crate::commands::plugins::plugin_get, + 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_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, @@ -315,6 +317,8 @@ mod tests { for command in [ "plugin_list", "plugin_get", + "plugin_preview_from_file", + "plugin_preview_update_from_file", "plugin_install_from_file", "plugin_update_from_file", "plugin_rollback", diff --git a/src/generated/bindings.ts b/src/generated/bindings.ts index 67fe5f8f..6b902728 100644 --- a/src/generated/bindings.ts +++ b/src/generated/bindings.ts @@ -1428,6 +1428,29 @@ export const commands = { else return { status: "error", error: e as any }; } }, + async pluginPreviewFromFile( + input: PluginPreviewFromFileInput + ): Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin_preview_from_file", { input }) }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + async pluginPreviewUpdateFromFile( + input: PluginPreviewUpdateFromFileInput + ): Promise> { + try { + return { + status: "ok", + data: await TAURI_INVOKE("plugin_preview_update_from_file", { input }), + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, async pluginInstallFromFile( input: PluginInstallFromFileInput ): Promise> { @@ -2685,6 +2708,14 @@ export type PluginAuditLog = { details: JsonValue; created_at: number; }; +export type PluginCompatibilitySummary = { + compatible: boolean; + hostVersion: string; + appRange: string; + pluginApiRange: string; + platforms: string[]; + blockingReasons: PluginLifecycleNotice[]; +}; export type PluginDetail = { summary: PluginSummary; manifest: PluginManifest; @@ -2699,8 +2730,34 @@ export type PluginDetail = { export type PluginGetInput = { pluginId: string }; export type PluginGrantPermissionsInput = { pluginId: string; permissions: string[] }; export type PluginHook = { name: string; priority?: number; failurePolicy?: string | null }; +export type PluginHookLifecycleSummary = { + name: string; + priority: number; + failurePolicy: string | null; +}; export type PluginHostCompatibility = { app: string; pluginApi: string; platforms?: string[] }; export type PluginInstallFromFileInput = { filePath: string }; +export type PluginInstallPreview = { + pluginId: string; + name: string; + version: string; + source: PluginInstallSource; + description: string | null; + author: JsonValue | null; + homepage: string | null; + repository: JsonValue | null; + license: string | null; + category: string | null; + runtime: PluginRuntimeLifecycleSummary; + hooks: PluginHookLifecycleSummary[]; + permissions: PluginPermissionLifecycleSummary[]; + compatibility: PluginCompatibilitySummary; + trust: PluginTrustSummary; + existingStatus: PluginStatus | null; + existingVersion: string | null; + blockingReasons: PluginLifecycleNotice[]; + warnings: PluginLifecycleNotice[]; +}; export type PluginInstallRemoteInput = { pluginId: string; downloadUrl: string; @@ -2710,6 +2767,13 @@ export type PluginInstallRemoteInput = { source: string | null; }; export type PluginInstallSource = "local" | "market" | "github_release" | "offline" | "official"; +export type PluginLifecycleChange = { + name: string; + change: string; + before: string | null; + after: string | null; +}; +export type PluginLifecycleNotice = { severity: string; code: string; message: string }; export type PluginListAuditLogsInput = { pluginId: string | null; limit: number | null }; export type PluginManifest = { id: string; @@ -2750,7 +2814,20 @@ export type PluginMarketListing = { updateAvailable: boolean; installBlockReason: string | null; }; +export type PluginPermissionLifecycleChange = { + permission: string; + risk: PluginPermissionRisk; + change: string; +}; +export type PluginPermissionLifecycleSummary = { + permission: string; + risk: PluginPermissionRisk; + granted: boolean; + pending: boolean; +}; export type PluginPermissionRisk = "low" | "medium" | "high" | "critical"; +export type PluginPreviewFromFileInput = { filePath: string }; +export type PluginPreviewUpdateFromFileInput = { filePath: string }; export type PluginRevokePermissionInput = { pluginId: string; permission: string }; export type PluginRollbackInput = { pluginId: string; version: string }; export type PluginRuntime = @@ -2766,6 +2843,12 @@ export type PluginRuntimeFailure = { trace_id: string | null; created_at: number; }; +export type PluginRuntimeLifecycleSummary = { + kind: string; + label: string; + supported: boolean; + blockingReasons: PluginLifecycleNotice[]; +}; export type PluginSaveConfigInput = { pluginId: string; config: JsonValue }; export type PluginStatus = | "available" @@ -2789,6 +2872,29 @@ export type PluginSummary = { created_at: number; updated_at: number; }; +export type PluginTrustSummary = { + checksum: string; + expectedChecksum: string | null; + checksumVerified: boolean; + signatureVerified: boolean; + unsigned: boolean; + developerMode: boolean; +}; +export type PluginUpdateDiff = { + pluginId: string; + fromVersion: string; + toVersion: string; + versionDirection: string; + runtimeChange: PluginLifecycleChange | null; + hookChanges: PluginLifecycleChange[]; + permissionChanges: PluginPermissionLifecycleChange[]; + configVersionChange: string | null; + compatibility: PluginCompatibilitySummary; + trust: PluginTrustSummary; + rollbackAvailable: boolean; + blockingReasons: PluginLifecycleNotice[]; + warnings: PluginLifecycleNotice[]; +}; export type PromptListSummary = { id: number; workspace_id: number; diff --git a/src/services/plugins.ts b/src/services/plugins.ts index f88c23e1..a20ae871 100644 --- a/src/services/plugins.ts +++ b/src/services/plugins.ts @@ -5,6 +5,7 @@ import { type JsonValue, type PluginAuditLog, type PluginDetail, + type PluginInstallPreview, type PluginInstallSource, type PluginManifest, type PluginMarketListing, @@ -12,6 +13,7 @@ import { type PluginRuntime, type PluginStatus, type PluginSummary, + type PluginUpdateDiff, } from "../generated/bindings"; import { invokeGeneratedIpc } from "./generatedIpc"; @@ -19,6 +21,7 @@ export type { JsonValue, PluginAuditLog, PluginDetail, + PluginInstallPreview, PluginInstallSource, PluginManifest, PluginMarketListing, @@ -26,6 +29,7 @@ export type { PluginRuntime, PluginStatus, PluginSummary, + PluginUpdateDiff, }; const PLUGIN_AUDIT_LOG_DEFAULT_LIMIT = 50; @@ -83,6 +87,28 @@ export async function pluginGet(pluginId: string) { }); } +export async function pluginPreviewFromFile(filePath: string) { + const normalizedFilePath = normalizePluginFilePath(filePath); + + return invokeGeneratedIpc({ + title: "预览插件失败", + cmd: "plugin_preview_from_file", + args: { filePath: normalizedFilePath }, + invoke: async () => commands.pluginPreviewFromFile({ filePath: normalizedFilePath }), + }); +} + +export async function pluginPreviewUpdateFromFile(filePath: string) { + const normalizedFilePath = normalizePluginFilePath(filePath); + + return invokeGeneratedIpc({ + title: "预览插件更新失败", + cmd: "plugin_preview_update_from_file", + args: { filePath: normalizedFilePath }, + invoke: async () => commands.pluginPreviewUpdateFromFile({ filePath: normalizedFilePath }), + }); +} + export async function pluginInstallFromFile(filePath: string) { const normalizedFilePath = normalizePluginFilePath(filePath); From 5b5200b5e994c6583f7e6161134c3d8f82c052bd Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Tue, 23 Jun 2026 22:59:14 +0800 Subject: [PATCH 067/244] feat: add plugin lifecycle previews --- src/pages/PluginsPage.tsx | 289 +++++++++++------- src/pages/__tests__/PluginsPage.test.tsx | 205 ++++++++++++- .../plugins/PluginInstallPreviewDialog.tsx | 203 ++++++++++++ src/pages/plugins/PluginLifecyclePanel.tsx | 122 ++++++++ .../plugins/PluginUpdatePreviewDialog.tsx | 260 ++++++++++++++++ src/query/keys.ts | 4 + src/query/plugins.ts | 24 ++ 7 files changed, 991 insertions(+), 116 deletions(-) create mode 100644 src/pages/plugins/PluginInstallPreviewDialog.tsx create mode 100644 src/pages/plugins/PluginLifecyclePanel.tsx create mode 100644 src/pages/plugins/PluginUpdatePreviewDialog.tsx diff --git a/src/pages/PluginsPage.tsx b/src/pages/PluginsPage.tsx index 88e167c0..2d7c5901 100644 --- a/src/pages/PluginsPage.tsx +++ b/src/pages/PluginsPage.tsx @@ -5,7 +5,6 @@ import { toast } from "sonner"; import { Copy, Download, - RotateCcw, Upload, Power, PowerOff, @@ -18,9 +17,11 @@ import { openDesktopSinglePath } from "../services/desktop/dialog"; import type { JsonValue, PluginDetail, + PluginInstallPreview, PluginPermissionRisk, PluginStatus, PluginSummary, + PluginUpdateDiff, } from "../services/plugins"; import { formatActionFailureToast, formatUnknownError } from "../utils/errors"; import { Button } from "../ui/Button"; @@ -32,6 +33,8 @@ import { usePluginGrantPermissionsMutation, usePluginInstallFromFileMutation, usePluginInstallOfficialMutation, + usePluginPreviewFromFileMutation, + usePluginPreviewUpdateFromFileMutation, usePluginQuery, usePluginRollbackMutation, usePluginSaveConfigMutation, @@ -40,6 +43,9 @@ import { usePluginsListQuery, } from "../query/plugins"; import { PluginConfigSchemaForm } from "./plugins/PluginConfigSchemaForm"; +import { PluginInstallPreviewDialog } from "./plugins/PluginInstallPreviewDialog"; +import { PluginLifecyclePanel } from "./plugins/PluginLifecyclePanel"; +import { PluginUpdatePreviewDialog } from "./plugins/PluginUpdatePreviewDialog"; import { describePluginPermission, describePluginRuntime, @@ -426,20 +432,16 @@ function PluginDetailPanel({ 更新 ) : null} - {rollbackVersion ? ( - - ) : null}
+ +
@@ -509,6 +511,16 @@ export function PluginsPage() { const listQuery = usePluginsListQuery(); const plugins = useMemo(() => listQuery.data ?? [], [listQuery.data]); const [selectedPluginId, setSelectedPluginId] = useState(null); + const [installPreviewState, setInstallPreviewState] = useState<{ + filePath: string; + preview: PluginInstallPreview; + } | null>(null); + const [updatePreviewState, setUpdatePreviewState] = useState<{ + filePath: string; + diff: PluginUpdateDiff; + } | null>(null); + const previewInstallMutation = usePluginPreviewFromFileMutation(); + const previewUpdateMutation = usePluginPreviewUpdateFromFileMutation(); const installMutation = usePluginInstallFromFileMutation(); const installOfficialMutation = usePluginInstallOfficialMutation(); const updateMutation = usePluginUpdateFromFileMutation(); @@ -531,6 +543,8 @@ export function PluginsPage() { ); const detailQuery = usePluginQuery(selectedPluginId, { enabled: Boolean(selectedPluginId) }); const busy = + previewInstallMutation.isPending || + previewUpdateMutation.isPending || installMutation.isPending || installOfficialMutation.isPending || updateMutation.isPending || @@ -545,8 +559,10 @@ export function PluginsPage() { try { await task(); toast.success(`${action}成功`); + return true; } catch (error) { toast.error(formatActionFailureToast(action, error).toast); + return false; } } @@ -556,7 +572,12 @@ export function PluginsPage() { filters: [{ name: "AIO plugin package", extensions: ["aio-plugin"] }], }); if (!filePath) return; - await runAction("导入插件", () => installMutation.mutateAsync(filePath)); + try { + const preview = await previewInstallMutation.mutateAsync(filePath); + setInstallPreviewState({ filePath, preview }); + } catch (error) { + toast.error(formatActionFailureToast("预览插件", error).toast); + } } async function handleUpdate() { @@ -565,7 +586,28 @@ export function PluginsPage() { filters: [{ name: "AIO plugin package", extensions: ["aio-plugin"] }], }); if (!filePath) return; - await runAction("更新插件", () => updateMutation.mutateAsync(filePath)); + try { + const diff = await previewUpdateMutation.mutateAsync(filePath); + setUpdatePreviewState({ filePath, diff }); + } catch (error) { + toast.error(formatActionFailureToast("预览更新", error).toast); + } + } + + async function confirmInstallPreview() { + if (!installPreviewState) return; + const done = await runAction("导入插件", () => + installMutation.mutateAsync(installPreviewState.filePath) + ); + if (done) setInstallPreviewState(null); + } + + async function confirmUpdatePreview() { + if (!updatePreviewState) return; + const done = await runAction("更新插件", () => + updateMutation.mutateAsync(updatePreviewState.filePath) + ); + if (done) setUpdatePreviewState(null); } if (listQuery.isLoading) { @@ -577,116 +619,135 @@ export function PluginsPage() { } return ( -
- - - 导入 .aio-plugin - - } - /> - - {listQuery.error ? ( -
- 插件列表加载失败:{formatUnknownError(listQuery.error)} -
- ) : null} - -
- 推荐插件 - {OFFICIAL_PLUGINS.map((plugin) => { - const installed = plugins.some((item) => item.plugin_id === plugin.id); - return ( - - ); - })} -
+ } + /> - {plugins.length === 0 && !listQuery.error ? ( -
-
还没有安装插件
-
- 可以安装官方 Privacy Filter,或导入 .aio-plugin 插件包。 + {listQuery.error ? ( +
+ 插件列表加载失败:{formatUnknownError(listQuery.error)}
+ ) : null} + +
+ 推荐插件 + {OFFICIAL_PLUGINS.map((plugin) => { + const installed = plugins.some((item) => item.plugin_id === plugin.id); + return ( + + ); + })}
- ) : ( -
-
-
- {plugins.map((plugin) => ( - setSelectedPluginId(plugin.plugin_id)} - onEnable={() => - runAction("启用插件", () => enableMutation.mutateAsync(plugin.plugin_id)) - } - onDisable={() => - runAction("禁用插件", () => disableMutation.mutateAsync(plugin.plugin_id)) - } - onUninstall={() => - runAction("卸载插件", () => uninstallMutation.mutateAsync(plugin.plugin_id)) - } - /> - ))} + + {plugins.length === 0 && !listQuery.error ? ( +
+
还没有安装插件
+
+ 可以安装官方 Privacy Filter,或导入 .aio-plugin 插件包。
+ ) : ( +
+
+
+ {plugins.map((plugin) => ( + setSelectedPluginId(plugin.plugin_id)} + onEnable={() => + runAction("启用插件", () => enableMutation.mutateAsync(plugin.plugin_id)) + } + onDisable={() => + runAction("禁用插件", () => disableMutation.mutateAsync(plugin.plugin_id)) + } + onUninstall={() => + runAction("卸载插件", () => uninstallMutation.mutateAsync(plugin.plugin_id)) + } + /> + ))} +
+
-
-
-
-
- {selectedSummary?.name ?? "插件详情"} -
-
- {selectedSummary?.plugin_id ?? "-"} +
+
+
+
+ {selectedSummary?.name ?? "插件详情"} +
+
+ {selectedSummary?.plugin_id ?? "-"} +
+ {detailQuery.isFetching ? : null}
- {detailQuery.isFetching ? : null} + { + if (!selectedPluginId) return; + runAction("回滚插件", () => + rollbackMutation.mutateAsync({ pluginId: selectedPluginId, version }) + ); + }} + onSaveConfig={(config) => { + if (!selectedPluginId) return; + runAction("保存配置", () => + saveConfigMutation.mutateAsync({ pluginId: selectedPluginId, config }) + ); + }} + onGrantPendingPermissions={(pluginId, permissions) => { + runAction("授权权限", () => + grantPermissionsMutation.mutateAsync({ pluginId, permissions }) + ); + }} + />
- { - if (!selectedPluginId) return; - runAction("回滚插件", () => - rollbackMutation.mutateAsync({ pluginId: selectedPluginId, version }) - ); - }} - onSaveConfig={(config) => { - if (!selectedPluginId) return; - runAction("保存配置", () => - saveConfigMutation.mutateAsync({ pluginId: selectedPluginId, config }) - ); - }} - onGrantPendingPermissions={(pluginId, permissions) => { - runAction("授权权限", () => - grantPermissionsMutation.mutateAsync({ pluginId, permissions }) - ); - }} - />
-
- )} -
+ )} +
+ + setInstallPreviewState(null)} + onConfirm={confirmInstallPreview} + /> + setUpdatePreviewState(null)} + onConfirm={confirmUpdatePreview} + /> + ); } diff --git a/src/pages/__tests__/PluginsPage.test.tsx b/src/pages/__tests__/PluginsPage.test.tsx index 57cc8c26..7f4cba5b 100644 --- a/src/pages/__tests__/PluginsPage.test.tsx +++ b/src/pages/__tests__/PluginsPage.test.tsx @@ -1,11 +1,16 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClientProvider } from "@tanstack/react-query"; import type { ReactElement } from "react"; import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { toast } from "sonner"; import { PluginsPage } from "../PluginsPage"; -import type { PluginDetail, PluginSummary } from "../../services/plugins"; +import type { + PluginDetail, + PluginInstallPreview, + PluginSummary, + PluginUpdateDiff, +} from "../../services/plugins"; import { openDesktopSinglePath } from "../../services/desktop/dialog"; import { createTestQueryClient } from "../../test/utils/reactQuery"; import { @@ -14,6 +19,8 @@ import { usePluginGrantPermissionsMutation, usePluginInstallFromFileMutation, usePluginInstallOfficialMutation, + usePluginPreviewFromFileMutation, + usePluginPreviewUpdateFromFileMutation, usePluginQuery, usePluginRollbackMutation, usePluginSaveConfigMutation, @@ -48,6 +55,8 @@ vi.mock("../../query/plugins", async () => { usePluginQuery: vi.fn(), usePluginInstallFromFileMutation: vi.fn(), usePluginInstallOfficialMutation: vi.fn(), + usePluginPreviewFromFileMutation: vi.fn(), + usePluginPreviewUpdateFromFileMutation: vi.fn(), usePluginUpdateFromFileMutation: vi.fn(), usePluginRollbackMutation: vi.fn(), usePluginEnableMutation: vi.fn(), @@ -122,6 +131,96 @@ function detail(overrides: Partial = {}): PluginDetail { }; } +function installPreview(overrides: Partial = {}): PluginInstallPreview { + return { + pluginId: "community.prompt-helper", + name: "Community Prompt Helper", + version: "1.0.0", + source: "local", + description: "Helps prompt editing", + author: null, + homepage: null, + repository: null, + license: "MIT", + category: "productivity", + runtime: { + kind: "declarativeRules", + label: "规则插件", + supported: true, + blockingReasons: [], + }, + hooks: [{ name: "gateway.request.afterBodyRead", priority: 100, failurePolicy: "fail-open" }], + permissions: [{ permission: "request.body.read", risk: "high", granted: false, pending: true }], + compatibility: { + compatible: true, + hostVersion: "0.62.2", + appRange: ">=0.56.0 <1.0.0", + pluginApiRange: "^1.0.0", + platforms: ["macos", "windows", "linux"], + blockingReasons: [], + }, + trust: { + checksum: "sha256-install", + expectedChecksum: null, + checksumVerified: false, + signatureVerified: false, + unsigned: true, + developerMode: false, + }, + existingStatus: null, + existingVersion: null, + blockingReasons: [], + warnings: [], + ...overrides, + }; +} + +function updateDiff(overrides: Partial = {}): PluginUpdateDiff { + return { + pluginId: "community.prompt-helper", + fromVersion: "1.0.0", + toVersion: "1.1.0", + versionDirection: "upgrade", + runtimeChange: null, + hookChanges: [ + { + name: "gateway.response.beforeSend", + change: "added", + before: null, + after: "priority 50", + }, + ], + permissionChanges: [ + { + permission: "request.body.write", + risk: "critical", + change: "added", + }, + ], + configVersionChange: "1 -> 2", + compatibility: { + compatible: true, + hostVersion: "0.62.2", + appRange: ">=0.56.0 <1.0.0", + pluginApiRange: "^1.0.0", + platforms: ["macos", "windows", "linux"], + blockingReasons: [], + }, + trust: { + checksum: "sha256-update", + expectedChecksum: null, + checksumVerified: false, + signatureVerified: false, + unsigned: true, + developerMode: false, + }, + rollbackAvailable: true, + blockingReasons: [], + warnings: [], + ...overrides, + }; +} + function mutation(overrides: Record = {}) { return { mutateAsync: vi.fn().mockResolvedValue(detail()), @@ -142,6 +241,12 @@ function renderWithProviders(element: ReactElement) { describe("pages/PluginsPage", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(usePluginPreviewFromFileMutation).mockReturnValue( + mutation({ mutateAsync: vi.fn().mockResolvedValue(installPreview()) }) as any + ); + vi.mocked(usePluginPreviewUpdateFromFileMutation).mockReturnValue( + mutation({ mutateAsync: vi.fn().mockResolvedValue(updateDiff()) }) as any + ); vi.mocked(usePluginInstallFromFileMutation).mockReturnValue(mutation() as any); vi.mocked(usePluginInstallOfficialMutation).mockReturnValue(mutation() as any); vi.mocked(usePluginUpdateFromFileMutation).mockReturnValue(mutation() as any); @@ -414,9 +519,11 @@ describe("pages/PluginsPage", () => { }); it("wires import and enable actions", async () => { + const previewMutation = mutation({ mutateAsync: vi.fn().mockResolvedValue(installPreview()) }); const importMutation = mutation(); const installOfficialMutation = mutation(); const enableMutation = mutation(); + vi.mocked(usePluginPreviewFromFileMutation).mockReturnValue(previewMutation as any); vi.mocked(usePluginInstallFromFileMutation).mockReturnValue(importMutation as any); vi.mocked(usePluginInstallOfficialMutation).mockReturnValue(installOfficialMutation as any); vi.mocked(usePluginEnableMutation).mockReturnValue(enableMutation as any); @@ -430,6 +537,13 @@ describe("pages/PluginsPage", () => { renderWithProviders(); fireEvent.click(screen.getByRole("button", { name: "导入 .aio-plugin" })); + + await screen.findByRole("dialog", { name: "安装前预检" }); + fireEvent.click(screen.getByRole("button", { name: "确认安装" })); + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "安装前预检" })).not.toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: /Privacy Filter/ })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Safety Detector/ })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Prompt Optimizer/ })).not.toBeInTheDocument(); @@ -440,6 +554,7 @@ describe("pages/PluginsPage", () => { fireEvent.click(screen.getByRole("button", { name: "启用" })); await waitFor(() => { + expect(previewMutation.mutateAsync).toHaveBeenCalledWith("/tmp/plugin.json"); expect(importMutation.mutateAsync).toHaveBeenCalledWith("/tmp/plugin.json"); expect(installOfficialMutation.mutateAsync).toHaveBeenCalledWith("official.privacy-filter"); expect(enableMutation.mutateAsync).toHaveBeenCalledWith("community.prompt-helper"); @@ -447,6 +562,36 @@ describe("pages/PluginsPage", () => { }); }); + it("previews local package before install", async () => { + const previewMutation = mutation({ mutateAsync: vi.fn().mockResolvedValue(installPreview()) }); + const importMutation = mutation(); + vi.mocked(usePluginPreviewFromFileMutation).mockReturnValue(previewMutation as any); + vi.mocked(usePluginInstallFromFileMutation).mockReturnValue(importMutation as any); + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary()], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(openDesktopSinglePath).mockResolvedValue("/tmp/prompt-helper.aio-plugin"); + + renderWithProviders(); + fireEvent.click(screen.getByRole("button", { name: "导入 .aio-plugin" })); + + const previewDialog = await screen.findByRole("dialog", { name: "安装前预检" }); + expect(within(previewDialog).getByText("Community Prompt Helper")).toBeInTheDocument(); + expect(within(previewDialog).getByText("sha256-install")).toBeInTheDocument(); + expect(importMutation.mutateAsync).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "确认安装" })); + + await waitFor(() => { + expect(previewMutation.mutateAsync).toHaveBeenCalledWith("/tmp/prompt-helper.aio-plugin"); + expect(importMutation.mutateAsync).toHaveBeenCalledWith("/tmp/prompt-helper.aio-plugin"); + expect(toast.success).toHaveBeenCalledWith("导入插件成功"); + }); + }); + it("approves pending plugin permissions from the detail panel", async () => { const grantMutation = mutation(); vi.mocked(usePluginGrantPermissionsMutation).mockReturnValue(grantMutation as any); @@ -496,8 +641,12 @@ describe("pages/PluginsPage", () => { }); it("shows package risk labels and wires update/rollback actions", async () => { + const previewUpdateMutation = mutation({ + mutateAsync: vi.fn().mockResolvedValue(updateDiff()), + }); const updateMutation = mutation(); const rollbackMutation = mutation(); + vi.mocked(usePluginPreviewUpdateFromFileMutation).mockReturnValue(previewUpdateMutation as any); vi.mocked(usePluginUpdateFromFileMutation).mockReturnValue(updateMutation as any); vi.mocked(usePluginRollbackMutation).mockReturnValue(rollbackMutation as any); vi.mocked(usePluginsListQuery).mockReturnValue({ @@ -554,12 +703,20 @@ describe("pages/PluginsPage", () => { renderWithProviders(); fireEvent.click(screen.getAllByText("Community Redactor")[0]); fireEvent.click(screen.getByRole("button", { name: "更新" })); + await screen.findByRole("dialog", { name: "更新预检" }); + fireEvent.click(screen.getByRole("button", { name: "确认更新" })); + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "更新预检" })).not.toBeInTheDocument(); + }); fireEvent.click(screen.getByRole("button", { name: "回滚 1.0.0" })); await waitFor(() => { expect(screen.getAllByText("未签名").length).toBeGreaterThan(0); expect(screen.getByText("已隔离")).toBeInTheDocument(); expect(screen.getByText("revoked by market")).toBeInTheDocument(); + expect(previewUpdateMutation.mutateAsync).toHaveBeenCalledWith( + "/tmp/community-redactor-1.1.0.aio-plugin" + ); expect(updateMutation.mutateAsync).toHaveBeenCalledWith( "/tmp/community-redactor-1.1.0.aio-plugin" ); @@ -570,6 +727,50 @@ describe("pages/PluginsPage", () => { }); }); + it("shows update diff before applying update", async () => { + const previewUpdateMutation = mutation({ + mutateAsync: vi.fn().mockResolvedValue(updateDiff()), + }); + const updateMutation = mutation(); + vi.mocked(usePluginPreviewUpdateFromFileMutation).mockReturnValue(previewUpdateMutation as any); + vi.mocked(usePluginUpdateFromFileMutation).mockReturnValue(updateMutation as any); + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary({ update_available: true })], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginQuery).mockReturnValue({ + data: detail({ + summary: summary({ update_available: true }), + }), + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(openDesktopSinglePath).mockResolvedValue("/tmp/prompt-helper-1.1.0.aio-plugin"); + + renderWithProviders(); + fireEvent.click(screen.getByRole("button", { name: "更新" })); + + const updateDialog = await screen.findByRole("dialog", { name: "更新预检" }); + expect(within(updateDialog).getByText("1.0.0 -> 1.1.0")).toBeInTheDocument(); + expect(within(updateDialog).getByText("gateway.response.beforeSend")).toBeInTheDocument(); + expect(updateMutation.mutateAsync).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "确认更新" })); + + await waitFor(() => { + expect(previewUpdateMutation.mutateAsync).toHaveBeenCalledWith( + "/tmp/prompt-helper-1.1.0.aio-plugin" + ); + expect(updateMutation.mutateAsync).toHaveBeenCalledWith( + "/tmp/prompt-helper-1.1.0.aio-plugin" + ); + expect(toast.success).toHaveBeenCalledWith("更新插件成功"); + }); + }); + it("does not offer enable action for quarantined or uninstalled plugins", () => { const enableMutation = mutation(); vi.mocked(usePluginEnableMutation).mockReturnValue(enableMutation as any); diff --git a/src/pages/plugins/PluginInstallPreviewDialog.tsx b/src/pages/plugins/PluginInstallPreviewDialog.tsx new file mode 100644 index 00000000..ae4e3f00 --- /dev/null +++ b/src/pages/plugins/PluginInstallPreviewDialog.tsx @@ -0,0 +1,203 @@ +// Usage: Shows local plugin package validation details before installation. + +import { AlertTriangle, CheckCircle2, ShieldAlert } from "lucide-react"; +import type { PluginInstallPreview } from "../../services/plugins"; +import { Button } from "../../ui/Button"; +import { Dialog } from "../../ui/Dialog"; +import { describePluginPermission, pluginRiskLabel } from "./pluginProductCopy"; + +type PluginInstallPreviewDialogProps = { + preview: PluginInstallPreview | null; + filePath: string | null; + open: boolean; + confirming: boolean; + onClose: () => void; + onConfirm: () => void; +}; + +type PluginLifecycleNotice = PluginInstallPreview["warnings"][number]; + +function sourceLabel(source: string) { + const labels: Record = { + local: "本地", + market: "市场", + github_release: "GitHub Release", + offline: "离线", + official: "官方", + }; + return labels[source] ?? source; +} + +function NoticeList({ notices }: { notices: readonly PluginLifecycleNotice[] }) { + if (notices.length === 0) return null; + return ( +
+ {notices.map((notice) => ( +
+
{notice.message}
+
{notice.code}
+
+ ))} +
+ ); +} + +export function PluginInstallPreviewDialog({ + preview, + filePath, + open, + confirming, + onClose, + onConfirm, +}: PluginInstallPreviewDialogProps) { + const blocked = Boolean(preview && preview.blockingReasons.length > 0); + const canConfirm = Boolean(preview) && !blocked; + + return ( + { + if (!nextOpen) onClose(); + }} + > + {!preview ? ( +
暂无插件预检结果。
+ ) : ( +
+
+
+
{preview.name}
+
+ {preview.pluginId} +
+
+
+ + {preview.version} + + + {sourceLabel(preview.source)} + + {preview.trust.unsigned ? ( + + 未签名 + + ) : ( + + 签名已验证 + + )} +
+
+ + {preview.description ? ( +
+ {preview.description} +
+ ) : null} + +
+
+
运行方式
+
{preview.runtime.label}
+
+ {preview.runtime.supported ? "当前宿主支持" : "当前宿主不支持"} +
+
+
+
兼容性
+
+ {preview.compatibility.compatible ? ( + + ) : ( + + )} + {preview.compatibility.compatible ? "可安装" : "需要处理阻断项"} +
+
+ app {preview.compatibility.appRange} / api {preview.compatibility.pluginApiRange} +
+
+
+
Checksum
+
+ {preview.trust.checksum} +
+
+
+ +
+
权限
+
+ {preview.permissions.length > 0 ? ( + preview.permissions.map((permission) => { + const copy = describePluginPermission(permission.permission); + return ( +
+
+
{copy.label}
+
{copy.detail}
+
+ {permission.permission} +
+
+ + {pluginRiskLabel(permission.risk)} + +
+ ); + }) + ) : ( +
+ 插件未请求额外权限 +
+ )} +
+
+ + {preview.warnings.length > 0 ? ( +
+
+ + 警告 +
+ +
+ ) : null} + + {preview.blockingReasons.length > 0 ? ( +
+
+ + 阻断项 +
+ +
+ ) : null} + + {filePath ? ( +
{filePath}
+ ) : null} + +
+ + +
+
+ )} +
+ ); +} diff --git a/src/pages/plugins/PluginLifecyclePanel.tsx b/src/pages/plugins/PluginLifecyclePanel.tsx new file mode 100644 index 00000000..652eff1e --- /dev/null +++ b/src/pages/plugins/PluginLifecyclePanel.tsx @@ -0,0 +1,122 @@ +// Usage: Summarizes plugin lifecycle status, source trust, quarantine, and rollback controls. + +import { RotateCcw, ShieldAlert } from "lucide-react"; +import type { JsonValue, PluginDetail } from "../../services/plugins"; +import { Button } from "../../ui/Button"; +import { pluginStatusLabel } from "./pluginProductCopy"; + +type PluginLifecyclePanelProps = { + detail: PluginDetail; + rollbackVersion: string | null; + busy: boolean; + onRollback: (version: string) => void; +}; + +const INSTALL_SOURCE_LABELS: Record = { + local: "本地", + market: "市场", + github_release: "GitHub Release", + offline: "离线", + official: "官方", +}; + +function jsonRecord(value: JsonValue): Record | null { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return null; +} + +function stringDetail(value: JsonValue | undefined) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function auditString(detail: PluginDetail, keys: readonly string[]) { + for (const log of detail.audit_logs) { + const details = jsonRecord(log.details); + if (!details) continue; + for (const key of keys) { + const value = stringDetail(details[key]); + if (value) return value; + } + } + return null; +} + +function isUnsigned(detail: PluginDetail) { + return detail.audit_logs.some((log) => jsonRecord(log.details)?.unsigned === true); +} + +function quarantineReason(detail: PluginDetail) { + const fromAudit = detail.audit_logs + .filter((log) => log.event_type === "plugin.quarantined") + .map((log) => stringDetail(jsonRecord(log.details)?.reason)) + .find(Boolean); + return fromAudit ?? detail.summary.last_error; +} + +export function PluginLifecyclePanel({ + detail, + rollbackVersion, + busy, + onRollback, +}: PluginLifecyclePanelProps) { + const checksum = auditString(detail, ["packageChecksum", "checksum"]); + const unsigned = isUnsigned(detail); + const sourceLabel = INSTALL_SOURCE_LABELS[detail.install_source] ?? detail.install_source; + const reason = quarantineReason(detail); + + return ( +
+
+

生命周期

+ {rollbackVersion ? ( + + ) : null} +
+ +
+
+
状态
+
{pluginStatusLabel(detail.summary.status)}
+
+
+
来源
+
{sourceLabel}
+
+
+
信任
+
{unsigned ? "未签名" : "签名已验证"}
+
+
+
回滚
+
+ {rollbackVersion ? `可回滚到 ${rollbackVersion}` : "暂无可回滚版本"} +
+
+
+
Checksum
+
{checksum ?? "-"}
+
+
+ + {detail.summary.status === "quarantined" || reason ? ( +
+ +
+
隔离原因
+
{reason ?? "插件已被隔离"}
+
+
+ ) : null} +
+ ); +} diff --git a/src/pages/plugins/PluginUpdatePreviewDialog.tsx b/src/pages/plugins/PluginUpdatePreviewDialog.tsx new file mode 100644 index 00000000..10db9714 --- /dev/null +++ b/src/pages/plugins/PluginUpdatePreviewDialog.tsx @@ -0,0 +1,260 @@ +// Usage: Shows local plugin package update diff before applying the update. + +import { AlertTriangle, CheckCircle2, RotateCcw, ShieldAlert } from "lucide-react"; +import type { PluginUpdateDiff } from "../../services/plugins"; +import { Button } from "../../ui/Button"; +import { Dialog } from "../../ui/Dialog"; +import { describePluginPermission, pluginRiskLabel } from "./pluginProductCopy"; + +type PluginUpdatePreviewDialogProps = { + diff: PluginUpdateDiff | null; + filePath: string | null; + open: boolean; + confirming: boolean; + onClose: () => void; + onConfirm: () => void; +}; + +type PluginLifecycleChange = PluginUpdateDiff["hookChanges"][number]; +type PluginLifecycleNotice = PluginUpdateDiff["warnings"][number]; +type PluginPermissionLifecycleChange = PluginUpdateDiff["permissionChanges"][number]; + +function changeLabel(change: string) { + const labels: Record = { + added: "新增", + removed: "移除", + changed: "变更", + unchanged: "未变", + }; + return labels[change] ?? change; +} + +function NoticeList({ notices }: { notices: readonly PluginLifecycleNotice[] }) { + if (notices.length === 0) return null; + return ( +
+ {notices.map((notice) => ( +
+
{notice.message}
+
{notice.code}
+
+ ))} +
+ ); +} + +function LifecycleChanges({ changes }: { changes: readonly PluginLifecycleChange[] }) { + if (changes.length === 0) { + return ( +
+ 没有 hook 变化 +
+ ); + } + + return ( +
+ {changes.map((change) => ( +
+
+ {change.name} + + {changeLabel(change.change)} + +
+
+ {change.before ?? "-"} -> {change.after ?? "-"} +
+
+ ))} +
+ ); +} + +function PermissionChanges({ changes }: { changes: readonly PluginPermissionLifecycleChange[] }) { + if (changes.length === 0) { + return ( +
+ 没有权限变化 +
+ ); + } + + return ( +
+ {changes.map((change) => { + const copy = describePluginPermission(change.permission); + return ( +
+
+
{copy.label}
+
{copy.detail}
+
+ {change.permission} +
+
+
+ + {changeLabel(change.change)} + + + {pluginRiskLabel(change.risk)} + +
+
+ ); + })} +
+ ); +} + +export function PluginUpdatePreviewDialog({ + diff, + filePath, + open, + confirming, + onClose, + onConfirm, +}: PluginUpdatePreviewDialogProps) { + const blocked = Boolean(diff && diff.blockingReasons.length > 0); + const canConfirm = Boolean(diff) && !blocked; + + return ( + { + if (!nextOpen) onClose(); + }} + > + {!diff ? ( +
暂无插件更新预检结果。
+ ) : ( +
+
+
+
{diff.pluginId}
+
+ {diff.fromVersion} -> {diff.toVersion} +
+
+
+ + {diff.versionDirection} + + {diff.trust.unsigned ? ( + + 未签名 + + ) : ( + + 签名已验证 + + )} +
+
+ +
+
+
兼容性
+
+ {diff.compatibility.compatible ? ( + + ) : ( + + )} + {diff.compatibility.compatible ? "可更新" : "需要处理阻断项"} +
+
+ app {diff.compatibility.appRange} / api {diff.compatibility.pluginApiRange} +
+
+
+
回滚
+
+ + {diff.rollbackAvailable ? "可回滚到当前版本" : "当前版本不可回滚"} +
+
+
+
Checksum
+
+ {diff.trust.checksum} +
+
+
+ + {diff.runtimeChange ? ( +
+
运行方式变化
+
+ {diff.runtimeChange.before ?? "-"} -> {diff.runtimeChange.after ?? "-"} +
+
+ ) : null} + +
+
Hook 变化
+ +
+ +
+
权限变化
+ +
+ + {diff.configVersionChange ? ( +
+
配置版本
+
{diff.configVersionChange}
+
+ ) : null} + + {diff.warnings.length > 0 ? ( +
+
+ + 警告 +
+ +
+ ) : null} + + {diff.blockingReasons.length > 0 ? ( +
+
+ + 阻断项 +
+ +
+ ) : null} + + {filePath ? ( +
{filePath}
+ ) : null} + +
+ + +
+
+ )} +
+ ); +} diff --git a/src/query/keys.ts b/src/query/keys.ts index 10ccddbe..f875ab55 100644 --- a/src/query/keys.ts +++ b/src/query/keys.ts @@ -240,6 +240,10 @@ export const pluginKeys = { all: pluginsAllKey, list: () => [...pluginsAllKey, "list"] as const, detail: (pluginId: string | null) => [...pluginsAllKey, "detail", pluginId] as const, + installPreview: (filePath: string | null) => + [...pluginsAllKey, "installPreview", filePath] as const, + updatePreview: (filePath: string | null) => + [...pluginsAllKey, "updatePreview", filePath] as const, auditLogs: (pluginId: string | null, limit: number | null) => [...pluginsAllKey, "auditLogs", pluginId, limit] as const, }; diff --git a/src/query/plugins.ts b/src/query/plugins.ts index 2feaf43a..d113f6b9 100644 --- a/src/query/plugins.ts +++ b/src/query/plugins.ts @@ -12,6 +12,8 @@ import { pluginInstallOfficial, pluginList, pluginListAuditLogs, + pluginPreviewFromFile, + pluginPreviewUpdateFromFile, pluginQuarantineRevoked, pluginRevokePermission, pluginRollback, @@ -98,6 +100,28 @@ export function usePluginInstallFromFileMutation() { }); } +export function usePluginPreviewFromFileMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (filePath: string) => pluginPreviewFromFile(filePath), + onSuccess: (next, filePath) => { + queryClient.setQueryData(pluginKeys.installPreview(filePath), next); + }, + }); +} + +export function usePluginPreviewUpdateFromFileMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (filePath: string) => pluginPreviewUpdateFromFile(filePath), + onSuccess: (next, filePath) => { + queryClient.setQueryData(pluginKeys.updatePreview(filePath), next); + }, + }); +} + export function usePluginUpdateFromFileMutation() { const queryClient = useQueryClient(); From 96e9241449443729a9a177f7d079c936d0ddb790 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Wed, 24 Jun 2026 07:19:28 +0800 Subject: [PATCH 068/244] fix(plugins): clarify lifecycle preview UI --- docs/plugins/developer-guide.md | 32 ++++++++- docs/plugins/reference/compatibility.md | 17 +++++ docs/plugins/reference/publishing.md | 32 ++++++++- src/pages/__tests__/PluginsPage.test.tsx | 65 ++++++++++++++++- .../plugins/PluginInstallPreviewDialog.tsx | 37 ++++++++++ src/pages/plugins/PluginLifecyclePanel.tsx | 15 ++++ .../plugins/PluginUpdatePreviewDialog.tsx | 72 ++++++++++++++++--- 7 files changed, 257 insertions(+), 13 deletions(-) diff --git a/docs/plugins/developer-guide.md b/docs/plugins/developer-guide.md index b500aab8..3406a812 100644 --- a/docs/plugins/developer-guide.md +++ b/docs/plugins/developer-guide.md @@ -22,7 +22,7 @@ 5. 使用 `pnpm create-aio-plugin doctor` 和 `validate --strict` 做 package health、manifest 与规则校验。 6. 使用 `pnpm create-aio-plugin replay --explain` 在本地 fixture 上验证行为并查看规则解释。 7. 使用 `pnpm create-aio-plugin pack` 打包为 `.aio-plugin`。 -8. 在 Plugins 页面本地导入,授权权限,启用插件,检查审计日志。 +8. 在 Plugins 页面本地导入,先检查安装预检,再确认安装、授权权限、启用插件,检查审计日志。 9. 发布前计算 `sha256`,可信索引分发时补 Ed25519 签名。 ## 10 分钟快速开始 @@ -74,7 +74,7 @@ pnpm create-aio-plugin replay --explain ./acme.redactor ./fixtures/codex-request pnpm create-aio-plugin pack ./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、hooks、permissions、host compatibility、checksum/signature 和风险提示无误后,再执行真实安装。安装后确认 `request.body.read` 和 `request.body.write` permissions 并启用插件。命中请求后,插件详情面板应展示 hook completion 或 block/failure events,且不应存储 sensitive payload text。 SDK 检查命令: @@ -199,6 +199,34 @@ Claude 和 Codex/OpenAI Responses 的请求结构不同。插件应避免只适 高风险和 critical 权限需要更明确的用户授权。插件升级新增权限后,宿主必须要求重新授权,不能静默继承。 +## 0.62.2 安装预检与更新差异 + +0.62.2 没有改变 Plugin API v1,也没有新增 Provider Plugin API。这个版本新增的是宿主侧生命周期解释层:在安装或更新前,把宿主已经会检查的 manifest、兼容性、runtime、hooks、permissions、checksum/signature 和来源信息集中展示出来。 + +安装本地 `.aio-plugin` 时,Plugins 页面会先生成预检结果: + +- 插件身份:id、name、version、description、author、homepage、repository、license。 +- 运行时:runtime kind、展示标签、当前宿主是否支持。 +- hooks:hook name、priority、failure policy。 +- 权限:permission、风险等级,以及是否已授权或待授权。 +- 兼容性:当前 app version、`hostCompatibility.app`、`pluginApi` 和平台 allowlist。 +- 信任信息:实际 checksum、期望 checksum、signature verified、unsigned、developer mode。 +- 生命周期提示:blocking reasons 和 warnings。 + +预检只是解释层,不是安全边界。用户确认后,真实安装仍会重新执行包解压安全检查、manifest 校验、host compatibility、runtime policy、checksum/signature 和权限策略检查。插件作者不能依赖“预检通过”跳过真实安装校验。 + +更新本地包时,Plugins 页面会先展示更新差异: + +- `fromVersion -> toVersion` 和 version direction。 +- runtime 是否变化。 +- hook 是否新增、删除或改变 priority/failure policy。 +- permission 是否 unchanged granted、unchanged pending、added pending 或 removed。 +- `configVersion` 和 compatibility 是否变化。 +- checksum/signature/source 信任状态。 +- 当前版本是否可以作为 rollback target。 + +新增权限会进入 pending,不会因为升级自动获得授权。更新后如果插件仍需要新增权限,用户必须重新授权后再获得对应能力。 + ## 配置表单 插件通过 `configSchema` 暴露用户配置。宿主负责渲染低代码表单并在保存前后校验。 diff --git a/docs/plugins/reference/compatibility.md b/docs/plugins/reference/compatibility.md index db7cffaf..7bbdbffa 100644 --- a/docs/plugins/reference/compatibility.md +++ b/docs/plugins/reference/compatibility.md @@ -33,3 +33,20 @@ Plugin API v1 remains externally compatible in 0.62. 这个版本重组的是宿 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. WASM remains policy-gated. The scaffold and pack flow can carry WASM artifacts, but marketplace WASM execution is not enabled by default. + +## 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、permissions、hooks、checksum/signature 和 package source 组合成 preview/diff 给用户确认。确认后,真实 install/update 仍会重新执行完整校验;preview/diff 不能作为跳过兼容性、签名或权限策略的依据。 + +兼容性判断仍以这些字段为准: + +- `hostCompatibility.app` 必须匹配当前 AIO Coding Hub 版本。 +- `hostCompatibility.pluginApi` 必须匹配当前 Plugin API v1。 +- `hostCompatibility.platforms` 如果存在,必须包含当前桌面平台。 +- runtime 必须由宿主策略支持。 + +Quarantined 和 incompatible 插件不能启用。更新新增的 permissions 会进入 pending,不会静默继承授权。 + +0.62.2 也不开放 browser-like plugin container。Linux、macOS、Windows 上的插件仍运行在宿主支持的本地 runtime 中;第三方插件不能把 AIO Coding Hub 内部变成浏览器或 WebView 插件容器。 diff --git a/docs/plugins/reference/publishing.md b/docs/plugins/reference/publishing.md index cda21a12..57e5697c 100644 --- a/docs/plugins/reference/publishing.md +++ b/docs/plugins/reference/publishing.md @@ -8,10 +8,40 @@ - 控制 package size 和 entry count。 - 对 package bytes 计算 `sha256`。 - 通过可信 index 发布时,用 Ed25519 签名 release metadata。 -- 对 breaking update 写清 rollback 说明。 +- 对新增权限、runtime/hook/config 变化和 breaking update 写清升级与 rollback 说明。 当前实现支持本地/离线包导入、受约束的远程 `.aio-plugin` 下载、checksum/signature verification、更新时的 permission delta 检查、已撤销插件 quarantine,以及 rollback snapshots。 +## 0.62.2 生命周期行为 + +0.62.2 把安装、更新、回滚和隔离相关信息收口为宿主侧 lifecycle explanation layer。它不改变 `plugin.json` v1 的字段形状,也不新增插件可调用 API。 + +### 安装预检 + +用户从 Plugins 页面导入本地 `.aio-plugin` 时,宿主会先读取包并展示安装预检。预检会说明插件身份、来源、runtime、hooks、permissions、兼容性、checksum/signature、已有安装覆盖关系、warnings 和 blocking reasons。 + +预检通过不等于安装已经完成。用户确认后,真实安装仍会重新执行包解压安全检查、manifest 校验、checksum/signature verification、host compatibility、runtime policy 和权限策略。发布者应该把预检视为“给用户解释将要发生什么”,而不是绕过安装校验的入口。 + +### 更新差异 + +本地更新前,宿主会比较当前已安装版本和待安装包,展示: + +- version direction。 +- runtime change。 +- hook added/removed/changed。 +- permission unchanged granted、unchanged pending、added pending、removed。 +- `configVersion` change。 +- compatibility 和 trust change。 +- 当前版本是否可回滚。 + +新增权限必须进入 pending。发布者可以在 release notes 中提前说明新增权限的原因,但宿主不会因为插件升级而静默授权新权限。 + +### 回滚与隔离 + +更新会保留可回滚的历史快照。rollback 只允许回到仓库已记录且仍可用的历史版本;如果当前版本的安装目录已经缺失,宿主会在更新预览里标记不可回滚。 + +撤销或宿主判定危险的插件会进入 `quarantined`。隔离插件不能启用;用户需要卸载、回滚到可用版本,或等待可信来源发布新版本。隔离和回滚会保留 audit 记录,便于追踪生命周期状态变化。 + 远程包安装刻意保持窄能力: - 下载 URL 必须是无凭据的 `https://` 或 `file://`。 diff --git a/src/pages/__tests__/PluginsPage.test.tsx b/src/pages/__tests__/PluginsPage.test.tsx index 7f4cba5b..e8f919bc 100644 --- a/src/pages/__tests__/PluginsPage.test.tsx +++ b/src/pages/__tests__/PluginsPage.test.tsx @@ -581,6 +581,10 @@ describe("pages/PluginsPage", () => { const previewDialog = await screen.findByRole("dialog", { name: "安装前预检" }); expect(within(previewDialog).getByText("Community Prompt Helper")).toBeInTheDocument(); expect(within(previewDialog).getByText("sha256-install")).toBeInTheDocument(); + expect(within(previewDialog).getByText("gateway.request.afterBodyRead")).toBeInTheDocument(); + expect( + within(previewDialog).getByText("预检只是解释层,最终安装仍会重新校验。") + ).toBeInTheDocument(); expect(importMutation.mutateAsync).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "确认安装" })); @@ -702,6 +706,12 @@ describe("pages/PluginsPage", () => { renderWithProviders(); fireEvent.click(screen.getAllByText("Community Redactor")[0]); + const lifecyclePanel = screen.getByText("生命周期").closest("section"); + expect(lifecyclePanel).not.toBeNull(); + expect(within(lifecyclePanel as HTMLElement).getByText("当前版本")).toBeInTheDocument(); + expect(within(lifecyclePanel as HTMLElement).getByText("1.1.0")).toBeInTheDocument(); + expect(within(lifecyclePanel as HTMLElement).getByText("有可用更新")).toBeInTheDocument(); + expect(within(lifecyclePanel as HTMLElement).getByText("最后更新")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "更新" })); await screen.findByRole("dialog", { name: "更新预检" }); fireEvent.click(screen.getByRole("button", { name: "确认更新" })); @@ -729,7 +739,17 @@ describe("pages/PluginsPage", () => { it("shows update diff before applying update", async () => { const previewUpdateMutation = mutation({ - mutateAsync: vi.fn().mockResolvedValue(updateDiff()), + mutateAsync: vi.fn().mockResolvedValue( + updateDiff({ + warnings: [ + { + severity: "warning", + code: "PLUGIN_MARKET_REVOKED", + message: "Plugin revoked by market index", + }, + ], + }) + ), }); const updateMutation = mutation(); vi.mocked(usePluginPreviewUpdateFromFileMutation).mockReturnValue(previewUpdateMutation as any); @@ -756,6 +776,8 @@ describe("pages/PluginsPage", () => { const updateDialog = await screen.findByRole("dialog", { name: "更新预检" }); expect(within(updateDialog).getByText("1.0.0 -> 1.1.0")).toBeInTheDocument(); expect(within(updateDialog).getByText("gateway.response.beforeSend")).toBeInTheDocument(); + expect(within(updateDialog).getByText("隔离与撤销")).toBeInTheDocument(); + expect(within(updateDialog).getByText("Plugin revoked by market index")).toBeInTheDocument(); expect(updateMutation.mutateAsync).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "确认更新" })); @@ -771,6 +793,47 @@ describe("pages/PluginsPage", () => { }); }); + it("keeps blocking revocation notices visually distinct in update preview", async () => { + const previewUpdateMutation = mutation({ + mutateAsync: vi.fn().mockResolvedValue( + updateDiff({ + blockingReasons: [ + { + severity: "error", + code: "PLUGIN_MARKET_REVOKED", + message: "Plugin revoked by market index", + }, + ], + }) + ), + }); + const updateMutation = mutation(); + vi.mocked(usePluginPreviewUpdateFromFileMutation).mockReturnValue(previewUpdateMutation as any); + vi.mocked(usePluginUpdateFromFileMutation).mockReturnValue(updateMutation as any); + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary({ update_available: true })], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginQuery).mockReturnValue({ + data: detail({ summary: summary({ update_available: true }) }), + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(openDesktopSinglePath).mockResolvedValue("/tmp/revoked-update.aio-plugin"); + + renderWithProviders(); + fireEvent.click(screen.getByRole("button", { name: "更新" })); + + const updateDialog = await screen.findByRole("dialog", { name: "更新预检" }); + expect(within(updateDialog).getByText("隔离/撤销阻断项")).toBeInTheDocument(); + expect(within(updateDialog).getByText("Plugin revoked by market index")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "确认更新" })).toBeDisabled(); + expect(updateMutation.mutateAsync).not.toHaveBeenCalled(); + }); + it("does not offer enable action for quarantined or uninstalled plugins", () => { const enableMutation = mutation(); vi.mocked(usePluginEnableMutation).mockReturnValue(enableMutation as any); diff --git a/src/pages/plugins/PluginInstallPreviewDialog.tsx b/src/pages/plugins/PluginInstallPreviewDialog.tsx index ae4e3f00..08baa682 100644 --- a/src/pages/plugins/PluginInstallPreviewDialog.tsx +++ b/src/pages/plugins/PluginInstallPreviewDialog.tsx @@ -16,6 +16,7 @@ type PluginInstallPreviewDialogProps = { }; type PluginLifecycleNotice = PluginInstallPreview["warnings"][number]; +type PluginHookLifecycleSummary = PluginInstallPreview["hooks"][number]; function sourceLabel(source: string) { const labels: Record = { @@ -45,6 +46,33 @@ function NoticeList({ notices }: { notices: readonly PluginLifecycleNotice[] }) ); } +function HookList({ hooks }: { hooks: readonly PluginHookLifecycleSummary[] }) { + if (hooks.length === 0) { + return ( +
+ 插件未声明 hook +
+ ); + } + + return ( +
+ {hooks.map((hook) => ( +
+
{hook.name}
+
+ priority {hook.priority} + {hook.failurePolicy ? ` / ${hook.failurePolicy}` : ""} +
+
+ ))} +
+ ); +} + export function PluginInstallPreviewDialog({ preview, filePath, @@ -102,6 +130,10 @@ export function PluginInstallPreviewDialog({
) : null} +
+ 预检只是解释层,最终安装仍会重新校验。 +
+
运行方式
@@ -164,6 +196,11 @@ export function PluginInstallPreviewDialog({
+
+
Hooks
+ +
+ {preview.warnings.length > 0 ? (
diff --git a/src/pages/plugins/PluginLifecyclePanel.tsx b/src/pages/plugins/PluginLifecyclePanel.tsx index 652eff1e..c4c9d8cd 100644 --- a/src/pages/plugins/PluginLifecyclePanel.tsx +++ b/src/pages/plugins/PluginLifecyclePanel.tsx @@ -3,6 +3,7 @@ import { RotateCcw, ShieldAlert } from "lucide-react"; import type { JsonValue, PluginDetail } from "../../services/plugins"; import { Button } from "../../ui/Button"; +import { formatUnixSeconds } from "../../utils/formatters"; import { pluginStatusLabel } from "./pluginProductCopy"; type PluginLifecyclePanelProps = { @@ -65,6 +66,8 @@ export function PluginLifecyclePanel({ const unsigned = isUnsigned(detail); const sourceLabel = INSTALL_SOURCE_LABELS[detail.install_source] ?? detail.install_source; const reason = quarantineReason(detail); + const currentVersion = detail.summary.current_version ?? detail.manifest.version ?? "-"; + const updateState = detail.summary.update_available ? "有可用更新" : "无可用更新"; return (
@@ -88,6 +91,18 @@ export function PluginLifecyclePanel({
状态
{pluginStatusLabel(detail.summary.status)}
+
+
当前版本
+
{currentVersion}
+
+
+
更新状态
+
{updateState}
+
+
+
最后更新
+
{formatUnixSeconds(detail.summary.updated_at)}
+
来源
{sourceLabel}
diff --git a/src/pages/plugins/PluginUpdatePreviewDialog.tsx b/src/pages/plugins/PluginUpdatePreviewDialog.tsx index 10db9714..a96b486c 100644 --- a/src/pages/plugins/PluginUpdatePreviewDialog.tsx +++ b/src/pages/plugins/PluginUpdatePreviewDialog.tsx @@ -18,6 +18,13 @@ type PluginUpdatePreviewDialogProps = { type PluginLifecycleChange = PluginUpdateDiff["hookChanges"][number]; type PluginLifecycleNotice = PluginUpdateDiff["warnings"][number]; type PluginPermissionLifecycleChange = PluginUpdateDiff["permissionChanges"][number]; +type NoticeVariant = "warning" | "destructive"; + +const LIFECYCLE_NOTICE_CODES = new Set([ + "PLUGIN_MARKET_REVOKED", + "PLUGIN_REVOKED", + "PLUGIN_QUARANTINED", +]); function changeLabel(change: string) { const labels: Record = { @@ -29,15 +36,22 @@ function changeLabel(change: string) { return labels[change] ?? change; } -function NoticeList({ notices }: { notices: readonly PluginLifecycleNotice[] }) { +function NoticeList({ + notices, + variant = "warning", +}: { + notices: readonly PluginLifecycleNotice[]; + variant?: NoticeVariant; +}) { if (notices.length === 0) return null; + const className = + variant === "destructive" + ? "rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" + : "rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning"; return (
{notices.map((notice) => ( -
+
{notice.message}
{notice.code}
@@ -46,6 +60,16 @@ function NoticeList({ notices }: { notices: readonly PluginLifecycleNotice[] }) ); } +function isQuarantineOrRevocationNotice(notice: PluginLifecycleNotice) { + const code = notice.code.toUpperCase(); + return ( + LIFECYCLE_NOTICE_CODES.has(code) || + code.endsWith("_REVOKED") || + code.endsWith("_QUARANTINED") || + code.includes("_QUARANTINE") + ); +} + function LifecycleChanges({ changes }: { changes: readonly PluginLifecycleChange[] }) { if (changes.length === 0) { return ( @@ -127,6 +151,16 @@ export function PluginUpdatePreviewDialog({ }: PluginUpdatePreviewDialogProps) { const blocked = Boolean(diff && diff.blockingReasons.length > 0); const canConfirm = Boolean(diff) && !blocked; + const lifecycleNotices = diff ? diff.warnings.filter(isQuarantineOrRevocationNotice) : []; + const lifecycleBlockingReasons = diff + ? diff.blockingReasons.filter(isQuarantineOrRevocationNotice) + : []; + const warnings = diff + ? diff.warnings.filter((notice) => !isQuarantineOrRevocationNotice(notice)) + : []; + const blockingReasons = diff + ? diff.blockingReasons.filter((notice) => !isQuarantineOrRevocationNotice(notice)) + : []; return ( ) : null} - {diff.warnings.length > 0 ? ( + {lifecycleBlockingReasons.length > 0 ? ( +
+
+ + 隔离/撤销阻断项 +
+ +
+ ) : null} + + {lifecycleNotices.length > 0 ? ( +
+
+ + 隔离与撤销 +
+ +
+ ) : null} + + {warnings.length > 0 ? (
警告
- +
) : null} - {diff.blockingReasons.length > 0 ? ( + {blockingReasons.length > 0 ? (
阻断项
- +
) : null} From 5891e6200fcc657074e946d7e51f26cf7cf781f3 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Wed, 24 Jun 2026 08:03:07 +0800 Subject: [PATCH 069/244] fix(plugins): align lifecycle rollback and trust state --- src-tauri/src/app/plugin_service.rs | 95 +++++++++- src-tauri/src/app/plugins/official.rs | 1 + .../official_privacy_filter_runtime.rs | 1 + src-tauri/src/app/plugins/rule_runtime.rs | 1 + src-tauri/src/app/plugins/runtime_executor.rs | 2 + src-tauri/src/commands/plugins.rs | 18 +- src-tauri/src/domain/plugins.rs | 1 + src-tauri/src/gateway/plugins/pipeline.rs | 1 + src-tauri/src/gateway/routes.rs | 4 + src-tauri/src/gateway/streams/plugin_chunk.rs | 1 + src-tauri/src/infra/plugins/repository.rs | 47 +++++ src/generated/bindings.ts | 1 + src/pages/PluginsPage.tsx | 22 ++- src/pages/__tests__/PluginsPage.test.tsx | 171 +++++++++++++++++- .../plugins/PluginInstallPreviewDialog.tsx | 20 +- src/pages/plugins/PluginLifecyclePanel.tsx | 30 ++- .../plugins/PluginUpdatePreviewDialog.tsx | 4 + src/query/__tests__/plugins.test.tsx | 1 + src/services/__tests__/plugins.test.ts | 1 + src/test/msw/state.ts | 1 + 20 files changed, 399 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/app/plugin_service.rs b/src-tauri/src/app/plugin_service.rs index 8073056d..22326cac 100644 --- a/src-tauri/src/app/plugin_service.rs +++ b/src-tauri/src/app/plugin_service.rs @@ -692,7 +692,7 @@ fn build_update_diff( 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| Path::new(&installed_dir).is_dir()) + .is_some_and(|installed_dir| repository::plugin_installed_dir_available(&installed_dir)) } fn rollback_candidate_installed_dir( @@ -896,6 +896,7 @@ pub(crate) fn install_plugin_from_local_package_with_policy( "packageChecksum": extracted.checksum, "cachedPackage": cache_package_path.to_string_lossy(), "unsigned": !trust.signature_verified, + "signatureVerified": trust.signature_verified, "developerMode": policy.developer_mode, }), )?; @@ -1131,6 +1132,7 @@ pub(crate) fn update_plugin_from_local_package( "toVersion": extracted.manifest.version, "pendingPermissions": pending, "unsigned": !trust.signature_verified, + "signatureVerified": trust.signature_verified, "developerMode": policy.developer_mode, }), )?; @@ -1156,6 +1158,18 @@ pub(crate) fn rollback_plugin_to_version( version: &str, ) -> AppResult { let (manifest, installed_dir) = repository::get_plugin_version(db, plugin_id, 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 detail = repository::update_plugin_manifest(db, manifest, installed_dir)?; append_audit( db, @@ -2684,6 +2698,31 @@ DROP TABLE plugins; assert!(repository::get_plugin(&db, "local.preview-incompatible").is_err()); } + #[test] + fn plugin_local_install_preview_blocks_unsigned_high_risk_with_default_policy() { + 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-risky.aio-plugin"); + let mut manifest = local_package_manifest("local.preview-risky", "1.0.0"); + manifest["permissions"] = serde_json::json!(["request.body.read"]); + 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::default(), + ) + .unwrap(); + + assert!(preview.blocking_reasons.iter().any(|notice| { + notice.code == "PLUGIN_UNSIGNED_HIGH_RISK_PERMISSION" + && notice.message.contains("request.body.read") + })); + assert!(repository::get_plugin(&db, "local.preview-risky").is_err()); + } + #[test] fn plugin_local_update_preview_reports_permission_runtime_hook_and_config_changes() { let dir = tempfile::tempdir().unwrap(); @@ -3694,4 +3733,58 @@ 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_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 { + allow_unsigned: true, + developer_mode: true, + ..LocalPackageInstallPolicy::default() + }, + ) + .unwrap(); + update_plugin_from_local_package( + &db, + &v2_package, + &cache_dir, + &installed_dir, + env!("CARGO_PKG_VERSION"), + LocalPackageInstallPolicy { + allow_unsigned: true, + 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/official.rs b/src-tauri/src/app/plugins/official.rs index 08e9996a..43b5e641 100644 --- a/src-tauri/src/app/plugins/official.rs +++ b/src-tauri/src/app/plugins/official.rs @@ -152,6 +152,7 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } diff --git a/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs b/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs index 34811891..315f3cf1 100644 --- a/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs +++ b/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs @@ -607,6 +607,7 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } diff --git a/src-tauri/src/app/plugins/rule_runtime.rs b/src-tauri/src/app/plugins/rule_runtime.rs index c2d16d1a..3a22172f 100644 --- a/src-tauri/src/app/plugins/rule_runtime.rs +++ b/src-tauri/src/app/plugins/rule_runtime.rs @@ -973,6 +973,7 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } diff --git a/src-tauri/src/app/plugins/runtime_executor.rs b/src-tauri/src/app/plugins/runtime_executor.rs index 9f49c423..5754bf11 100644 --- a/src-tauri/src/app/plugins/runtime_executor.rs +++ b/src-tauri/src/app/plugins/runtime_executor.rs @@ -283,6 +283,7 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } @@ -348,6 +349,7 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } } diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs index 0c351507..8432012c 100644 --- a/src-tauri/src/commands/plugins.rs +++ b/src-tauri/src/commands/plugins.rs @@ -125,11 +125,7 @@ fn official_resource_root_exists(root: &std::path::Path) -> bool { } fn local_plugin_preview_policy() -> plugin_service::LocalPackageInstallPolicy { - plugin_service::LocalPackageInstallPolicy { - allow_unsigned: true, - developer_mode: true, - ..Default::default() - } + plugin_service::LocalPackageInstallPolicy::default() } #[tauri::command] @@ -741,4 +737,16 @@ 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.allow_unsigned); + 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()); + } } diff --git a/src-tauri/src/domain/plugins.rs b/src-tauri/src/domain/plugins.rs index dcdec60b..6c5d049b 100644 --- a/src-tauri/src/domain/plugins.rs +++ b/src-tauri/src/domain/plugins.rs @@ -204,6 +204,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)] diff --git a/src-tauri/src/gateway/plugins/pipeline.rs b/src-tauri/src/gateway/plugins/pipeline.rs index fab8267e..e59a801c 100644 --- a/src-tauri/src/gateway/plugins/pipeline.rs +++ b/src-tauri/src/gateway/plugins/pipeline.rs @@ -1147,6 +1147,7 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } diff --git a/src-tauri/src/gateway/routes.rs b/src-tauri/src/gateway/routes.rs index cc104761..d53675ff 100644 --- a/src-tauri/src/gateway/routes.rs +++ b/src-tauri/src/gateway/routes.rs @@ -853,6 +853,7 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } @@ -945,6 +946,7 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } @@ -1225,6 +1227,7 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], }; repository::insert_plugin( &db, @@ -1346,6 +1349,7 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], }; repository::insert_plugin( &db, diff --git a/src-tauri/src/gateway/streams/plugin_chunk.rs b/src-tauri/src/gateway/streams/plugin_chunk.rs index eeb7a9d7..d60049f3 100644 --- a/src-tauri/src/gateway/streams/plugin_chunk.rs +++ b/src-tauri/src/gateway/streams/plugin_chunk.rs @@ -246,6 +246,7 @@ mod tests { pending_permissions: vec![], audit_logs: vec![], runtime_failures: vec![], + rollback_versions: vec![], } } diff --git a/src-tauri/src/infra/plugins/repository.rs b/src-tauri/src/infra/plugins/repository.rs index 246f2b39..778a3396 100644 --- a/src-tauri/src/infra/plugins/repository.rs +++ b/src-tauri/src/infra/plugins/repository.rs @@ -147,6 +147,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,9 +160,54 @@ 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 insert_plugin(db: &db::Db, input: InsertPluginInput) -> AppResult { validate_manifest(&input.manifest, env!("CARGO_PKG_VERSION"))?; let conn = db.open_connection()?; diff --git a/src/generated/bindings.ts b/src/generated/bindings.ts index 6b902728..a0c8c3d2 100644 --- a/src/generated/bindings.ts +++ b/src/generated/bindings.ts @@ -2726,6 +2726,7 @@ export type PluginDetail = { pending_permissions: string[]; audit_logs: PluginAuditLog[]; runtime_failures: PluginRuntimeFailure[]; + rollback_versions: string[]; }; export type PluginGetInput = { pluginId: string }; export type PluginGrantPermissionsInput = { pluginId: string; permissions: string[] }; diff --git a/src/pages/PluginsPage.tsx b/src/pages/PluginsPage.tsx index 2d7c5901..f21e8406 100644 --- a/src/pages/PluginsPage.tsx +++ b/src/pages/PluginsPage.tsx @@ -98,17 +98,25 @@ function detailValue(details: JsonValue, key: string) { return trimmed ? trimmed : null; } +const TRUST_EVENT_TYPES = new Set([ + "plugin.installed", + "plugin.updated", + "plugin.rollback", + "plugin.official.installed", +]); + +function latestTrustAudit(detail: PluginDetail) { + return detail.audit_logs + .filter((log) => TRUST_EVENT_TYPES.has(log.event_type)) + .sort((a, b) => b.created_at - a.created_at || b.id - a.id)[0]; +} + function isUnsigned(detail: PluginDetail) { - return detail.audit_logs.some((log) => jsonRecord(log.details)?.unsigned === true); + return jsonRecord(latestTrustAudit(detail)?.details)?.unsigned === true; } function previousVersion(detail: PluginDetail) { - const current = detail.summary.current_version ?? detail.manifest.version; - const fromAudit = detail.audit_logs - .map((log) => jsonRecord(log.details)?.fromVersion ?? null) - .find((value): value is string => typeof value === "string" && value !== current); - if (fromAudit) return fromAudit; - return current === "1.0.0" ? null : "1.0.0"; + return detail.rollback_versions[0] ?? null; } function Section({ title, children }: { title: string; children: React.ReactNode }) { diff --git a/src/pages/__tests__/PluginsPage.test.tsx b/src/pages/__tests__/PluginsPage.test.tsx index e8f919bc..3482f526 100644 --- a/src/pages/__tests__/PluginsPage.test.tsx +++ b/src/pages/__tests__/PluginsPage.test.tsx @@ -127,6 +127,7 @@ function detail(overrides: Partial = {}): PluginDetail { }, ], runtime_failures: [], + rollback_versions: [], ...overrides, }; } @@ -194,7 +195,7 @@ function updateDiff(overrides: Partial = {}): PluginUpdateDiff { permission: "request.body.write", risk: "critical", - change: "added", + change: "added_pending", }, ], configVersionChange: "1 -> 2", @@ -303,6 +304,87 @@ describe("pages/PluginsPage", () => { expect(screen.getByText("读取你发送给模型的内容")).toBeInTheDocument(); }); + it("does not present unknown audit trust as verified", () => { + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary()], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginQuery).mockReturnValue({ + data: detail({ + audit_logs: [ + { + id: 12, + plugin_id: "community.prompt-helper", + trace_id: null, + event_type: "plugin.installed", + risk_level: "low", + message: "Plugin installed before trust audit fields", + details: {}, + created_at: 42, + }, + ], + }), + isLoading: false, + isFetching: false, + error: null, + } as any); + + renderWithProviders(); + + const lifecyclePanel = screen.getByText("生命周期").closest("section"); + expect(lifecyclePanel).not.toBeNull(); + expect(within(lifecyclePanel as HTMLElement).getByText("签名状态未记录")).toBeInTheDocument(); + expect(screen.queryByText("签名已验证")).not.toBeInTheDocument(); + }); + + it("uses only the latest lifecycle audit for trust state", () => { + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary()], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginQuery).mockReturnValue({ + data: detail({ + audit_logs: [ + { + id: 13, + plugin_id: "community.prompt-helper", + trace_id: null, + event_type: "plugin.rollback", + risk_level: "high", + message: "Plugin rolled back", + details: { version: "1.0.0" }, + created_at: 50, + }, + { + id: 12, + plugin_id: "community.prompt-helper", + trace_id: null, + event_type: "plugin.updated", + risk_level: "low", + message: "Plugin updated from signed package", + details: { signatureVerified: true, unsigned: false }, + created_at: 40, + }, + ], + }), + isLoading: false, + isFetching: false, + error: null, + } as any); + + renderWithProviders(); + + const lifecyclePanel = screen.getByText("生命周期").closest("section"); + expect(lifecyclePanel).not.toBeNull(); + expect(within(lifecyclePanel as HTMLElement).getByText("签名状态未记录")).toBeInTheDocument(); + expect(screen.queryByText("签名已验证")).not.toBeInTheDocument(); + expect(screen.queryByText("未签名")).not.toBeInTheDocument(); + }); + it("renders runtime failures in the runtime observability section", async () => { const { copyText } = await import("../../services/clipboard"); vi.mocked(usePluginsListQuery).mockReturnValue({ @@ -475,6 +557,7 @@ describe("pages/PluginsPage", () => { }, }, }, + install_source: "official", config: { sensitiveTypes: ["email", "cn_phone"] }, granted_permissions: ["request.body.read", "request.body.write", "log.redact"], }), @@ -486,6 +569,7 @@ describe("pages/PluginsPage", () => { renderWithProviders(); expect(screen.getByText("检测策略")).toBeInTheDocument(); + expect(screen.getByText("官方来源")).toBeInTheDocument(); expect(screen.getAllByText(/200\+ Gitleaks/).length).toBeGreaterThanOrEqual(2); expect(screen.getByLabelText("邮箱地址")).toBeChecked(); expect(screen.queryByLabelText("sensitiveTypes")).not.toBeInTheDocument(); @@ -596,6 +680,44 @@ describe("pages/PluginsPage", () => { }); }); + it("blocks install confirmation for destructive preview reasons", async () => { + const previewMutation = mutation({ + mutateAsync: vi.fn().mockResolvedValue( + installPreview({ + blockingReasons: [ + { + severity: "error", + code: "PLUGIN_UNSIGNED_HIGH_RISK_PERMISSION", + message: "Unsigned plugin cannot request high-risk permission", + }, + ], + }) + ), + }); + const importMutation = mutation(); + vi.mocked(usePluginPreviewFromFileMutation).mockReturnValue(previewMutation as any); + vi.mocked(usePluginInstallFromFileMutation).mockReturnValue(importMutation as any); + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary()], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(openDesktopSinglePath).mockResolvedValue("/tmp/risky-plugin.aio-plugin"); + + renderWithProviders(); + fireEvent.click(screen.getByRole("button", { name: "导入 .aio-plugin" })); + + const previewDialog = await screen.findByRole("dialog", { name: "安装前预检" }); + const reason = within(previewDialog).getByText( + "Unsigned plugin cannot request high-risk permission" + ); + expect(within(previewDialog).getByText("阻断项")).toBeInTheDocument(); + expect(reason.closest(".text-destructive")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "确认安装" })).toBeDisabled(); + expect(importMutation.mutateAsync).not.toHaveBeenCalled(); + }); + it("approves pending plugin permissions from the detail panel", async () => { const grantMutation = mutation(); vi.mocked(usePluginGrantPermissionsMutation).mockReturnValue(grantMutation as any); @@ -644,6 +766,51 @@ describe("pages/PluginsPage", () => { expect(screen.getByRole("button", { name: "授权待审批权限" })).toBeInTheDocument(); }); + it("does not infer rollback targets from audit details", () => { + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [ + summary({ + plugin_id: "community.redactor", + name: "Community Redactor", + current_version: "1.1.0", + }), + ], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginQuery).mockReturnValue({ + data: detail({ + summary: summary({ + plugin_id: "community.redactor", + name: "Community Redactor", + current_version: "1.1.0", + }), + audit_logs: [ + { + id: 2, + plugin_id: "community.redactor", + trace_id: null, + event_type: "plugin.updated", + risk_level: "medium", + message: "Plugin updated", + details: { fromVersion: "1.0.0" }, + created_at: 40, + }, + ], + rollback_versions: [], + }), + isLoading: false, + isFetching: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.queryByRole("button", { name: "回滚 1.0.0" })).not.toBeInTheDocument(); + expect(screen.getByText("暂无可回滚版本")).toBeInTheDocument(); + }); + it("shows package risk labels and wires update/rollback actions", async () => { const previewUpdateMutation = mutation({ mutateAsync: vi.fn().mockResolvedValue(updateDiff()), @@ -697,6 +864,7 @@ describe("pages/PluginsPage", () => { created_at: 40, }, ], + rollback_versions: ["1.0.0"], }), isLoading: false, isFetching: false, @@ -776,6 +944,7 @@ describe("pages/PluginsPage", () => { const updateDialog = await screen.findByRole("dialog", { name: "更新预检" }); expect(within(updateDialog).getByText("1.0.0 -> 1.1.0")).toBeInTheDocument(); expect(within(updateDialog).getByText("gateway.response.beforeSend")).toBeInTheDocument(); + expect(within(updateDialog).getByText("新增,待授权")).toBeInTheDocument(); expect(within(updateDialog).getByText("隔离与撤销")).toBeInTheDocument(); expect(within(updateDialog).getByText("Plugin revoked by market index")).toBeInTheDocument(); expect(updateMutation.mutateAsync).not.toHaveBeenCalled(); diff --git a/src/pages/plugins/PluginInstallPreviewDialog.tsx b/src/pages/plugins/PluginInstallPreviewDialog.tsx index 08baa682..66a61e97 100644 --- a/src/pages/plugins/PluginInstallPreviewDialog.tsx +++ b/src/pages/plugins/PluginInstallPreviewDialog.tsx @@ -17,6 +17,7 @@ type PluginInstallPreviewDialogProps = { type PluginLifecycleNotice = PluginInstallPreview["warnings"][number]; type PluginHookLifecycleSummary = PluginInstallPreview["hooks"][number]; +type NoticeVariant = "warning" | "destructive"; function sourceLabel(source: string) { const labels: Record = { @@ -29,15 +30,22 @@ function sourceLabel(source: string) { return labels[source] ?? source; } -function NoticeList({ notices }: { notices: readonly PluginLifecycleNotice[] }) { +function NoticeList({ + notices, + variant = "warning", +}: { + notices: readonly PluginLifecycleNotice[]; + variant?: NoticeVariant; +}) { if (notices.length === 0) return null; + const className = + variant === "destructive" + ? "rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" + : "rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning"; return (
{notices.map((notice) => ( -
+
{notice.message}
{notice.code}
@@ -217,7 +225,7 @@ export function PluginInstallPreviewDialog({ 阻断项
- +
) : null} diff --git a/src/pages/plugins/PluginLifecyclePanel.tsx b/src/pages/plugins/PluginLifecyclePanel.tsx index c4c9d8cd..8ce5b298 100644 --- a/src/pages/plugins/PluginLifecyclePanel.tsx +++ b/src/pages/plugins/PluginLifecyclePanel.tsx @@ -44,8 +44,30 @@ function auditString(detail: PluginDetail, keys: readonly string[]) { return null; } -function isUnsigned(detail: PluginDetail) { - return detail.audit_logs.some((log) => jsonRecord(log.details)?.unsigned === true); +const TRUST_EVENT_TYPES = new Set([ + "plugin.installed", + "plugin.updated", + "plugin.rollback", + "plugin.official.installed", +]); + +function latestTrustAudit(detail: PluginDetail) { + return detail.audit_logs + .filter((log) => TRUST_EVENT_TYPES.has(log.event_type)) + .sort((a, b) => b.created_at - a.created_at || b.id - a.id)[0]; +} + +function trustLabel(detail: PluginDetail) { + const latest = latestTrustAudit(detail); + const details = jsonRecord(latest?.details); + if (details?.unsigned === true) return "未签名"; + if (details?.signatureVerified === true || details?.signature_verified === true) { + return "签名已验证"; + } + if (latest?.event_type === "plugin.official.installed" || detail.install_source === "official") { + return "官方来源"; + } + return "签名状态未记录"; } function quarantineReason(detail: PluginDetail) { @@ -63,11 +85,11 @@ export function PluginLifecyclePanel({ onRollback, }: PluginLifecyclePanelProps) { const checksum = auditString(detail, ["packageChecksum", "checksum"]); - const unsigned = isUnsigned(detail); const sourceLabel = INSTALL_SOURCE_LABELS[detail.install_source] ?? detail.install_source; const reason = quarantineReason(detail); const currentVersion = detail.summary.current_version ?? detail.manifest.version ?? "-"; const updateState = detail.summary.update_available ? "有可用更新" : "无可用更新"; + const trust = trustLabel(detail); return (
@@ -109,7 +131,7 @@ export function PluginLifecyclePanel({
信任
-
{unsigned ? "未签名" : "签名已验证"}
+
{trust}
回滚
diff --git a/src/pages/plugins/PluginUpdatePreviewDialog.tsx b/src/pages/plugins/PluginUpdatePreviewDialog.tsx index a96b486c..594fda6c 100644 --- a/src/pages/plugins/PluginUpdatePreviewDialog.tsx +++ b/src/pages/plugins/PluginUpdatePreviewDialog.tsx @@ -29,9 +29,13 @@ const LIFECYCLE_NOTICE_CODES = new Set([ function changeLabel(change: string) { const labels: Record = { added: "新增", + added_pending: "新增,待授权", removed: "移除", changed: "变更", unchanged: "未变", + unchanged_granted: "已授权", + unchanged_pending: "待授权", + unchanged_requested: "仍需授权", }; return labels[change] ?? change; } diff --git a/src/query/__tests__/plugins.test.tsx b/src/query/__tests__/plugins.test.tsx index 2b6443f7..8acb3f2d 100644 --- a/src/query/__tests__/plugins.test.tsx +++ b/src/query/__tests__/plugins.test.tsx @@ -90,6 +90,7 @@ function detail(overrides: Partial = {}): PluginDetail { pending_permissions: [], audit_logs: [], runtime_failures: [], + rollback_versions: [], ...overrides, }; } diff --git a/src/services/__tests__/plugins.test.ts b/src/services/__tests__/plugins.test.ts index 4fbc59ce..384a367e 100644 --- a/src/services/__tests__/plugins.test.ts +++ b/src/services/__tests__/plugins.test.ts @@ -81,6 +81,7 @@ function pluginDetail(install_source: PluginDetail["install_source"] = "local"): pending_permissions: [], audit_logs: [], runtime_failures: [], + rollback_versions: [], }; } diff --git a/src/test/msw/state.ts b/src/test/msw/state.ts index c31d6716..53a4250c 100644 --- a/src/test/msw/state.ts +++ b/src/test/msw/state.ts @@ -412,6 +412,7 @@ function officialPrivacyFilterDetail(): PluginDetail { }, ], runtime_failures: [], + rollback_versions: [], }; } From 0e993229d049a06498e50304e405cfa5e2e232ae Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Wed, 24 Jun 2026 09:51:05 +0800 Subject: [PATCH 070/244] fix(plugins): align manifest api version contract --- docs/plugin-manifest-v1.md | 14 +++++----- docs/plugins/reference/compatibility.md | 2 +- docs/plugins/reference/hooks.md | 4 +-- packages/plugin-sdk/src/index.test.ts | 13 +++++++++ packages/plugin-sdk/src/index.ts | 13 +++++++-- scripts/check-plugin-api-contract.mjs | 17 ++++++++++++ src-tauri/src/domain/plugins.rs | 36 ++++++++++++++++++++++++- 7 files changed, 86 insertions(+), 13 deletions(-) diff --git a/docs/plugin-manifest-v1.md b/docs/plugin-manifest-v1.md index f38d3aa7..946f41bd 100644 --- a/docs/plugin-manifest-v1.md +++ b/docs/plugin-manifest-v1.md @@ -44,7 +44,7 @@ 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 @@ -102,12 +102,12 @@ Active hooks in plugin API v1 是当前已经接入 gateway 或 log pipeline 的 | Hook | 触发时机 | 可修改内容 | 默认超时 | 默认失败策略 | 匹配权限 | | --- | --- | --- | --- | --- | --- | -| `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 | 150 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 | 150 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 | 150 ms | fail-open | `stream.inspect`, `stream.modify` | +| `gateway.response.after` | 大小预算内的完整 non-stream response | headers 和 response body | 150 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 | 150 ms | fail-open | `response.header.read`, `response.body.read`, `response.header.write`, `response.body.write` | +| `log.beforePersist` | Request 或 audit log 持久化前 | log message | 150 ms | fail-open | `log.redact` | Streaming hooks 接收 bounded chunks 和固定大小 sliding window,不会接收无限制完整响应。 diff --git a/docs/plugins/reference/compatibility.md b/docs/plugins/reference/compatibility.md index 7bbdbffa..adb323fd 100644 --- a/docs/plugins/reference/compatibility.md +++ b/docs/plugins/reference/compatibility.md @@ -16,7 +16,7 @@ WASM 插件还需要声明 WASM ABI 版本: { "kind": "wasm", "abiVersion": "1.0.0" } ``` -宿主会拒绝不支持的主版本。未来插件 API 变更必须保持向后兼容;无法兼容时,需要提升主版本并让旧插件继续按旧契约运行或被明确标记为不兼容。 +宿主会拒绝不支持的主版本。0.62.x 只支持 Plugin API major `1`,因此 manifest 的 `apiVersion` 必须是 `1.x.y`,即使 `hostCompatibility.pluginApi` 声明支持 `^1.0.0` 也不能使用 `2.0.0`。未来插件 API 变更必须保持向后兼容;无法兼容时,需要提升主版本并让旧插件继续按旧契约运行或被明确标记为不兼容。 ## 0.62 Internal Platform Kernel diff --git a/docs/plugins/reference/hooks.md b/docs/plugins/reference/hooks.md index 998ce02f..79bf1db6 100644 --- a/docs/plugins/reference/hooks.md +++ b/docs/plugins/reference/hooks.md @@ -2,8 +2,8 @@ Hooks 是网关和日志 pipeline 中稳定的扩展点。Plugin API v1 刻意保持 active surface 小而明确,让社区插件能清楚判断调用时机、权限边界和 mutation 行为。 -默认 vNext hook timeout: 150 ms. -默认 vNext failure policy: `fail-open`. +默认 v1 hook timeout: 150 ms. +默认 v1 failure policy: `fail-open`. Reserved hooks 在宿主实现对应调用点前,会被 manifest validation 拒绝: diff --git a/packages/plugin-sdk/src/index.test.ts b/packages/plugin-sdk/src/index.test.ts index 76b0fbb0..71d9273b 100644 --- a/packages/plugin-sdk/src/index.test.ts +++ b/packages/plugin-sdk/src/index.test.ts @@ -160,6 +160,19 @@ describe("validateManifest", () => { }); }); + it("rejects future manifest apiVersion majors even when hostCompatibility supports v1", () => { + const result = validateManifest({ + ...manifest, + apiVersion: "2.0.0", + hostCompatibility: { app: ">=0.56.0 <1.0.0", pluginApi: "^1.0.0" }, + }); + + expect(result).toMatchObject({ + ok: false, + error: { code: "PLUGIN_INCOMPATIBLE_API" }, + }); + }); + it("rejects wasm ABI versions outside v1", () => { const result = validateManifest({ ...manifest, diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index b0b9b26b..1e8731bd 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -204,8 +204,17 @@ 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 (!isSemver(manifest.apiVersion)) { + return invalid("PLUGIN_INVALID_API_VERSION", "apiVersion must be SemVer"); + } + if (semverMajor(manifest.apiVersion) !== 1) { + return invalid( + "PLUGIN_INCOMPATIBLE_API", + "apiVersion must use plugin API major version 1" + ); } if (manifest.runtime.kind === "declarativeRules" && manifest.runtime.rules.length === 0) { return invalid("PLUGIN_INVALID_RUNTIME", "declarativeRules runtime requires rules"); diff --git a/scripts/check-plugin-api-contract.mjs b/scripts/check-plugin-api-contract.mjs index 119d38e0..69dc0126 100644 --- a/scripts/check-plugin-api-contract.mjs +++ b/scripts/check-plugin-api-contract.mjs @@ -413,6 +413,23 @@ 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); diff --git a/src-tauri/src/domain/plugins.rs b/src-tauri/src/domain/plugins.rs index 6c5d049b..b87cc475 100644 --- a/src-tauri/src/domain/plugins.rs +++ b/src-tauri/src/domain/plugins.rs @@ -357,7 +357,7 @@ pub fn validate_manifest( ) -> Result<(), PluginValidationError> { validate_plugin_id(&manifest.id)?; validate_semver(&manifest.version, "PLUGIN_INVALID_VERSION")?; - validate_semver(&manifest.api_version, "PLUGIN_INVALID_API_VERSION")?; + validate_manifest_api_version(&manifest.api_version)?; validate_runtime(manifest)?; validate_hooks(&manifest.hooks)?; validate_permissions(&manifest.permissions)?; @@ -424,6 +424,26 @@ fn validate_semver(version: &str, code: &str) -> Result<(), PluginValidationErro .ok_or_else(|| PluginValidationError::new(code, format!("invalid SemVer: {version}"))) } +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_runtime(manifest: &PluginManifest) -> Result<(), PluginValidationError> { match &manifest.runtime { PluginRuntime::DeclarativeRules { rules } => { @@ -849,6 +869,20 @@ mod tests { 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_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.56.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(); From 818a43d04febb2cc3579a959d4042f530d57c093 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Wed, 24 Jun 2026 10:08:38 +0800 Subject: [PATCH 071/244] feat(plugins): add runtime lifecycle registry --- docs/plugins/runtime/README.md | 11 ++ docs/plugins/runtime/process-poc.md | 4 + docs/plugins/runtime/wasm.md | 4 + src-tauri/src/app/plugins/mod.rs | 1 + .../official_privacy_filter_runtime.rs | 19 ++++ src-tauri/src/app/plugins/rule_runtime.rs | 19 ++++ src-tauri/src/app/plugins/runtime_executor.rs | 106 ++++++++++++++++-- .../src/app/plugins/runtime_lifecycle.rs | 98 ++++++++++++++++ 8 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 src-tauri/src/app/plugins/runtime_lifecycle.rs diff --git a/docs/plugins/runtime/README.md b/docs/plugins/runtime/README.md index 17d65999..4b7796e5 100644 --- a/docs/plugins/runtime/README.md +++ b/docs/plugins/runtime/README.md @@ -7,3 +7,14 @@ - [流式响应插件](./streaming.md):`gateway.response.chunk`、sliding window 和 `stream.modify` 的边界。 声明式规则属于插件作者最常用的社区运行时,契约文档在 [Declarative Rules](../reference/declarative-rules.md)。 + +## Host Runtime Lifecycle + +0.62.3 treats runtime lifecycle as a host-owned internal contract: + +1. **Load**: parse and validate runtime artifacts under package limits. +2. **Execute**: run a bounded hook invocation with timeout and mutation checks. +3. **Retain**: keep only runtime caches that correspond to the current enabled plugin snapshot. +4. **Dispose**: clear runtime caches when plugins are disabled, updated, uninstalled, or when the gateway plugin snapshot is replaced. + +Community `declarativeRules` and official `native:privacyFilter` are the only runtimes wired into gateway execution. WASM remains policy-gated, and process runtime remains PoC-only until both are routed through the same lifecycle registry with memory, IO, timeout, and shutdown guarantees. diff --git a/docs/plugins/runtime/process-poc.md b/docs/plugins/runtime/process-poc.md index 8ec9b0c6..cbbcb871 100644 --- a/docs/plugins/runtime/process-poc.md +++ b/docs/plugins/runtime/process-poc.md @@ -78,3 +78,7 @@ M5 backend tests 覆盖: - hook handling 期间 sleep 的 child 会触发 hook timeout 并被 kill。 - 提前退出的 child 会被报告为 crash isolation,而不是 host crash。 - healthy idle child 会在 idle recycle 后被回收。 + +## 0.62.3 Lifecycle Boundary + +The process runtime is a PoC and is not part of the public Plugin API v1 execution surface. It must remain disabled for community plugins until it is owned by the host lifecycle registry with start timeout, hook timeout, idle recycle, hard shutdown, stdout/stderr drain limits, request/response byte limits, and per-plugin process concurrency limits. diff --git a/docs/plugins/runtime/wasm.md b/docs/plugins/runtime/wasm.md index 4cf3348a..ec2c5871 100644 --- a/docs/plugins/runtime/wasm.md +++ b/docs/plugins/runtime/wasm.md @@ -132,3 +132,7 @@ SDK 和示例检查命令: ```bash pnpm plugin-wasm-sdk:test ``` + +## 0.62.3 Lifecycle Boundary + +WASM remains policy-gated. A WASM package can be validated only when host policy allows it, and community WASM execution must not be enabled until the runtime is wired through the host lifecycle registry. The required lifecycle guarantees are: bounded input/output JSON, bounded guest memory, bounded fuel or equivalent execution budget, module/cache eviction on plugin snapshot refresh, and disposal on disable/update/uninstall. diff --git a/src-tauri/src/app/plugins/mod.rs b/src-tauri/src/app/plugins/mod.rs index 45962332..91f8ecc7 100644 --- a/src-tauri/src/app/plugins/mod.rs +++ b/src-tauri/src/app/plugins/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod process_runtime; pub(crate) mod rule_runtime; pub(crate) mod runtime_cache; pub(crate) mod runtime_executor; +pub(crate) mod runtime_lifecycle; pub(crate) mod runtime_manager; pub(crate) mod runtime_policy; pub(crate) mod wasm_runtime; diff --git a/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs b/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs index 315f3cf1..3f1e28b3 100644 --- a/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs +++ b/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs @@ -2,6 +2,7 @@ use super::privacy_filter::{PrivacyFilter, PrivacyFilterError, PrivacyFilterOptions}; use super::runtime_cache::{runtime_cache_key, RuntimeCacheKeyInput}; +use super::runtime_lifecycle::PluginRuntimeCache; use crate::gateway::plugins::context::{GatewayHookResult, GatewayVisibleHookContext}; use crate::gateway::plugins::permissions::GatewayPluginError; use crate::plugins::PluginDetail; @@ -38,6 +39,14 @@ impl OfficialPrivacyFilterRuntime { .retain(|key, _| privacy_keys.contains(key)); } + #[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, @@ -72,6 +81,16 @@ impl OfficialPrivacyFilterRuntime { } } +impl PluginRuntimeCache for OfficialPrivacyFilterRuntime { + fn retain_for_plugins(&self, plugins: &[PluginDetail]) { + self.retain_runtime_caches_for_plugins(plugins); + } + + fn clear_all(&self) { + self.clear_runtime_caches(); + } +} + fn privacy_filter_cache_key(plugin: &PluginDetail) -> String { let version = plugin .summary diff --git a/src-tauri/src/app/plugins/rule_runtime.rs b/src-tauri/src/app/plugins/rule_runtime.rs index 3a22172f..d9f3ee47 100644 --- a/src-tauri/src/app/plugins/rule_runtime.rs +++ b/src-tauri/src/app/plugins/rule_runtime.rs @@ -1,6 +1,7 @@ //! Usage: Declarative, no-code plugin rule runtime. use super::runtime_cache::{runtime_cache_key, RuntimeCacheKeyInput}; +use super::runtime_lifecycle::PluginRuntimeCache; use crate::gateway::plugins::context::{ GatewayHookAction, GatewayHookResult, GatewayVisibleHookContext, }; @@ -226,6 +227,14 @@ impl RuleRuntimeGatewayPluginExecutor { .retain(|key, _| rule_keys.contains(key)); } + #[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_rule_runtime( &self, plugin: &PluginDetail, @@ -254,6 +263,16 @@ impl RuleRuntimeGatewayPluginExecutor { } } +impl PluginRuntimeCache for RuleRuntimeGatewayPluginExecutor { + fn retain_for_plugins(&self, plugins: &[PluginDetail]) { + self.retain_runtime_caches_for_plugins(plugins); + } + + fn clear_all(&self) { + self.clear_runtime_caches(); + } +} + fn rule_runtime_cache_key(plugin: &PluginDetail) -> String { let version = plugin .summary diff --git a/src-tauri/src/app/plugins/runtime_executor.rs b/src-tauri/src/app/plugins/runtime_executor.rs index 5754bf11..ca081a90 100644 --- a/src-tauri/src/app/plugins/runtime_executor.rs +++ b/src-tauri/src/app/plugins/runtime_executor.rs @@ -2,35 +2,47 @@ use crate::app::plugins::official_privacy_filter_runtime::OfficialPrivacyFilterRuntime; use crate::app::plugins::rule_runtime::RuleRuntimeGatewayPluginExecutor; +use crate::app::plugins::runtime_lifecycle::RuntimeLifecycleRegistry; use crate::app::plugins::runtime_manager::{PluginRuntimeManager, RuntimeDispatch}; use crate::app::plugins::runtime_policy::RuntimePolicy; use crate::domain::plugins::PluginDetail; use crate::gateway::plugins::context::{GatewayHookResult, GatewayVisibleHookContext}; use crate::gateway::plugins::permissions::GatewayPluginError; use crate::gateway::plugins::pipeline::{GatewayHookFuture, GatewayPluginExecutor}; +use std::sync::Arc; #[derive(Debug, Clone, Copy, Default)] pub(crate) struct RuntimeExecutionPolicy { pub(crate) wasm_enabled: bool, } -#[derive(Default)] pub(crate) struct RuntimeGatewayPluginExecutor { - rule_runtime: RuleRuntimeGatewayPluginExecutor, - privacy_filter_runtime: OfficialPrivacyFilterRuntime, + rule_runtime: Arc, + privacy_filter_runtime: Arc, + lifecycle: RuntimeLifecycleRegistry, policy: RuntimeExecutionPolicy, } impl RuntimeGatewayPluginExecutor { - #[cfg(test)] - pub(crate) fn for_tests(policy: RuntimeExecutionPolicy) -> Self { + pub(crate) fn new(policy: RuntimeExecutionPolicy) -> Self { + let rule_runtime = Arc::new(RuleRuntimeGatewayPluginExecutor::default()); + let privacy_filter_runtime = Arc::new(OfficialPrivacyFilterRuntime::default()); + let lifecycle = RuntimeLifecycleRegistry::default(); + lifecycle.register_cache(rule_runtime.clone()); + lifecycle.register_cache(privacy_filter_runtime.clone()); Self { - rule_runtime: RuleRuntimeGatewayPluginExecutor::default(), - privacy_filter_runtime: OfficialPrivacyFilterRuntime::default(), + rule_runtime, + privacy_filter_runtime, + lifecycle, policy, } } + #[cfg(test)] + pub(crate) fn for_tests(policy: RuntimeExecutionPolicy) -> Self { + Self::new(policy) + } + pub(crate) fn execute_plugin_sync( &self, plugin: &PluginDetail, @@ -56,9 +68,12 @@ impl RuntimeGatewayPluginExecutor { } pub(crate) fn retain_runtime_caches_for_plugins(&self, plugins: &[PluginDetail]) { - self.rule_runtime.retain_runtime_caches_for_plugins(plugins); - self.privacy_filter_runtime - .retain_runtime_caches_for_plugins(plugins); + self.lifecycle.retain_for_plugins(plugins); + } + + #[cfg(test)] + pub(crate) fn dispose_runtime_caches_for_tests(&self) { + self.lifecycle.dispose_all(); } #[cfg(test)] @@ -67,6 +82,12 @@ impl RuntimeGatewayPluginExecutor { } } +impl Default for RuntimeGatewayPluginExecutor { + fn default() -> Self { + Self::new(RuntimeExecutionPolicy::default()) + } +} + impl GatewayPluginExecutor for RuntimeGatewayPluginExecutor { fn retain_runtime_caches_for_plugins(&self, plugins: &[PluginDetail]) { self.retain_runtime_caches_for_plugins(plugins); @@ -212,6 +233,54 @@ mod tests { assert_eq!(executor.privacy_filter_cache_size_for_tests(), 0); } + #[test] + fn runtime_executor_disposes_registered_runtime_caches() { + let executor = executor(); + let privacy_plugin = official_privacy_filter_plugin_detail(serde_json::json!({ + "redactBeforeUpstream": true, + "redactLogs": true + })); + let privacy_context = hook_context("log.beforePersist", "trace-dispose"); + + executor + .execute_plugin_sync(&privacy_plugin, privacy_context) + .expect("official privacy filter runtime executes"); + assert_eq!(executor.privacy_filter_cache_size_for_tests(), 1); + + 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"); + let rule_file = rules_dir.join("main.json"); + write_replace_rule_file(&rule_file, "[FIRST]"); + let rule_plugin = + rule_plugin_detail("example.rules", dir.path().to_string_lossy().to_string()); + let rule_context = hook_context("gateway.request.afterBodyRead", "trace-rule-dispose"); + + let first_result = executor + .execute_plugin_sync(&rule_plugin, rule_context) + .expect("rule runtime executes"); + assert!(first_result + .request_body + .as_deref() + .is_some_and(|body| body.contains("[FIRST]"))); + write_replace_rule_file(&rule_file, "[SECOND]"); + + executor.dispose_runtime_caches_for_tests(); + + let second_result = executor + .execute_plugin_sync( + &rule_plugin, + hook_context("gateway.request.afterBodyRead", "trace-rule-dispose-reload"), + ) + .expect("rule runtime reloads after dispose"); + + assert_eq!(executor.privacy_filter_cache_size_for_tests(), 0); + assert!(second_result + .request_body + .as_deref() + .is_some_and(|body| body.contains("[SECOND]"))); + } + fn executor() -> RuntimeGatewayPluginExecutor { RuntimeGatewayPluginExecutor::for_tests(RuntimeExecutionPolicy { wasm_enabled: false, @@ -287,6 +356,23 @@ mod tests { } } + fn write_replace_rule_file(path: &std::path::Path, replacement: &str) { + std::fs::write( + path, + json!({ + "rules": [{ + "id": "replace-message", + "hook": "gateway.request.afterBodyRead", + "target": { "field": "request.body" }, + "match": { "regex": "hello" }, + "action": { "kind": "replace", "replacement": replacement } + }] + }) + .to_string(), + ) + .expect("rule file"); + } + fn plugin_detail( plugin_id: &str, runtime: PluginRuntime, 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..027dbe30 --- /dev/null +++ b/src-tauri/src/app/plugins/runtime_lifecycle.rs @@ -0,0 +1,98 @@ +//! 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); +} + +#[derive(Default)] +pub(crate) struct RuntimeLifecycleRegistry { + caches: RwLock>>, +} + +impl RuntimeLifecycleRegistry { + pub(crate) fn register_cache(&self, cache: Arc) { + self.caches + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(cache); + } + + pub(crate) fn retain_for_plugins(&self, plugins: &[PluginDetail]) { + for cache in self.caches_snapshot() { + cache.retain_for_plugins(plugins); + } + } + + #[allow(dead_code)] + pub(crate) fn dispose_all(&self) { + for cache in self.caches_snapshot() { + cache.clear_all(); + } + } + + fn caches_snapshot(&self) -> Vec> { + self.caches + .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, + } + + 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; + } + } + + #[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); + } +} From 096ef7c4a5efac16a1b320d312e5903c1143a7db Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Wed, 24 Jun 2026 10:41:50 +0800 Subject: [PATCH 072/244] feat(plugins): add hook resource budgets --- docs/plugins/reference/hooks.md | 6 + .../official_privacy_filter_runtime.rs | 1 + src-tauri/src/app/plugins/rule_runtime.rs | 2 + src-tauri/src/gateway/plugins/context.rs | 364 +++++++++++++++++- src-tauri/src/gateway/plugins/mutation.rs | 122 ++++++ src-tauri/src/gateway/plugins/pipeline.rs | 72 +++- src-tauri/src/gateway/proxy/errors.rs | 7 +- 7 files changed, 542 insertions(+), 32 deletions(-) diff --git a/docs/plugins/reference/hooks.md b/docs/plugins/reference/hooks.md index 79bf1db6..737ef3f7 100644 --- a/docs/plugins/reference/hooks.md +++ b/docs/plugins/reference/hooks.md @@ -11,6 +11,12 @@ Reserved hooks 在宿主实现对应调用点前,会被 manifest validation - `gateway.request.beforeProviderResolution` - `gateway.response.headers` +## Resource Budgets + +Plugin hook contexts are permission-trimmed and budget-trimmed. 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. + ## Hook 矩阵 | Hook | 阶段 | 读权限 | 写权限 | Mutation fields | Context fields | diff --git a/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs b/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs index 3f1e28b3..5daa1da0 100644 --- a/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs +++ b/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs @@ -596,6 +596,7 @@ mod tests { stream: GatewayVisibleStreamContext::default(), log: GatewayVisibleLogContext { message: Some(message.to_string()), + ..GatewayVisibleLogContext::default() }, } } diff --git a/src-tauri/src/app/plugins/rule_runtime.rs b/src-tauri/src/app/plugins/rule_runtime.rs index d9f3ee47..8f7d4454 100644 --- a/src-tauri/src/app/plugins/rule_runtime.rs +++ b/src-tauri/src/app/plugins/rule_runtime.rs @@ -913,6 +913,7 @@ mod tests { status: Some(200), headers: None, body: Some(body.to_string()), + ..GatewayVisibleResponseContext::default() }, stream: GatewayVisibleStreamContext::default(), log: GatewayVisibleLogContext::default(), @@ -928,6 +929,7 @@ mod tests { stream: GatewayVisibleStreamContext { sequence: Some(1), chunk: Some(chunk.to_string()), + ..GatewayVisibleStreamContext::default() }, log: GatewayVisibleLogContext::default(), } diff --git a/src-tauri/src/gateway/plugins/context.rs b/src-tauri/src/gateway/plugins/context.rs index 8908d50d..6dc93a1c 100644 --- a/src-tauri/src/gateway/plugins/context.rs +++ b/src-tauri/src/gateway/plugins/context.rs @@ -5,6 +5,33 @@ use axum::http::{HeaderMap, Method}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +pub(crate) const DEFAULT_PLUGIN_CONTEXT_BODY_BYTES: usize = 256 * 1024; +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: DEFAULT_PLUGIN_CONTEXT_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, @@ -83,7 +110,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") { @@ -102,9 +138,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 @@ -121,7 +164,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") @@ -132,7 +184,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 } @@ -146,14 +200,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 } @@ -166,13 +232,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 } @@ -217,7 +294,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, } @@ -226,17 +305,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)] @@ -278,49 +360,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, + ); } } } @@ -328,11 +478,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 { @@ -348,7 +512,15 @@ fn collect_responses_input_item(item: &serde_json::Value, out: &mut Vec {} @@ -369,13 +541,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(), }); } @@ -492,6 +673,151 @@ mod tests { ); } + #[test] + fn visible_request_context_truncates_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 visible_stream_and_log_context_truncate_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 visible_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, + ..GatewayPluginContextBudget::default() + }; + + 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/mutation.rs b/src-tauri/src/gateway/plugins/mutation.rs index c75de1b5..614e9dfc 100644 --- a/src-tauri/src/gateway/plugins/mutation.rs +++ b/src-tauri/src/gateway/plugins/mutation.rs @@ -4,10 +4,51 @@ use super::context::{GatewayHookResult, GatewayPluginHookName}; use super::permissions::GatewayPluginError; use super::registry::HookDescriptor; +pub(crate) const DEFAULT_PLUGIN_MUTATION_BODY_BYTES: usize = 256 * 1024; +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: DEFAULT_PLUGIN_MUTATION_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")?; @@ -28,9 +69,62 @@ pub(crate) fn enforce_descriptor_permissions( 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, @@ -150,4 +244,32 @@ mod tests { 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/pipeline.rs b/src-tauri/src/gateway/plugins/pipeline.rs index e59a801c..8f47bd61 100644 --- a/src-tauri/src/gateway/plugins/pipeline.rs +++ b/src-tauri/src/gateway/plugins/pipeline.rs @@ -1,13 +1,15 @@ //! 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::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::domain::plugins::{PluginDetail, PluginStatus}; use axum::body::Bytes; use axum::http::{HeaderMap, HeaderName, HeaderValue}; @@ -89,6 +91,8 @@ 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 { @@ -97,6 +101,8 @@ impl Default for GatewayPluginPipelineConfig { hook_timeout: Duration::from_millis(150), circuit_failure_threshold: 3, circuit_cooldown: Duration::from_secs(30), + context_budget: GatewayPluginContextBudget::default(), + mutation_budget: GatewayPluginMutationBudget::default(), } } } @@ -239,7 +245,10 @@ impl GatewayPluginPipeline { body: body.clone(), ..input.clone() }; - let visible = current_input.visible_context(&plugin.granted_permissions); + let visible = current_input.visible_context_with_budget( + &plugin.granted_permissions, + self.config.context_budget, + ); 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, @@ -285,10 +294,11 @@ impl GatewayPluginPipeline { } }; - if let Err(err) = enforce_descriptor_result_permissions( + if let Err(err) = enforce_hook_result_with_budget( input.hook_name, &plugin.granted_permissions, &result, + self.config.mutation_budget, ) { self.record_failure(&plugin.summary.plugin_id); audit_events.push(audit_event( @@ -378,7 +388,10 @@ impl GatewayPluginPipeline { body: body.clone(), ..input.clone() }; - let visible = current_input.visible_context(&plugin.granted_permissions); + let visible = current_input.visible_context_with_budget( + &plugin.granted_permissions, + self.config.context_budget, + ); let result = match tokio::time::timeout( self.config.hook_timeout, self.executor.execute_response_hook(plugin, visible), @@ -405,10 +418,11 @@ impl GatewayPluginPipeline { } }; - if let Err(err) = enforce_descriptor_result_permissions( + if let Err(err) = enforce_hook_result_with_budget( input.hook_name, &plugin.granted_permissions, &result, + self.config.mutation_budget, ) { self.record_failure(&plugin.summary.plugin_id); audit_events.push(failed_event(plugin, input.hook_name, &err.to_string())); @@ -483,7 +497,10 @@ impl GatewayPluginPipeline { chunk: chunk.clone(), ..input.clone() }; - let visible = current_input.visible_context(&plugin.granted_permissions); + let visible = current_input.visible_context_with_budget( + &plugin.granted_permissions, + self.config.context_budget, + ); let result = match tokio::time::timeout( self.config.hook_timeout, self.executor.execute_stream_hook(plugin, visible), @@ -510,10 +527,11 @@ impl GatewayPluginPipeline { } }; - if let Err(err) = enforce_descriptor_result_permissions( + if let Err(err) = enforce_hook_result_with_budget( hook_name, &plugin.granted_permissions, &result, + self.config.mutation_budget, ) { self.record_failure(&plugin.summary.plugin_id); audit_events.push(failed_event(plugin, hook_name, &err.to_string())); @@ -583,7 +601,10 @@ impl GatewayPluginPipeline { message: message.clone(), ..input.clone() }; - let visible = current_input.visible_context(&plugin.granted_permissions); + let visible = current_input.visible_context_with_budget( + &plugin.granted_permissions, + self.config.context_budget, + ); let result = match tokio::time::timeout( self.config.hook_timeout, self.executor.execute_log_hook(plugin, visible), @@ -610,10 +631,11 @@ impl GatewayPluginPipeline { } }; - if let Err(err) = enforce_descriptor_result_permissions( + if let Err(err) = enforce_hook_result_with_budget( hook_name, &plugin.granted_permissions, &result, + self.config.mutation_budget, ) { self.record_failure(&plugin.summary.plugin_id); audit_events.push(failed_event(plugin, hook_name, &err.to_string())); @@ -755,6 +777,29 @@ enum FailurePolicy { FailClosed, } +fn enforce_hook_result_with_budget( + hook_name: GatewayPluginHookName, + permissions: &[String], + result: &GatewayHookResult, + budget: GatewayPluginMutationBudget, +) -> Result<(), GatewayPluginError> { + 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 failure_policy(plugin: &PluginDetail, hook_name: GatewayPluginHookName) -> FailurePolicy { plugin_hook(plugin, hook_name) .and_then(|hook| hook.failure_policy.as_deref()) @@ -1371,6 +1416,7 @@ mod tests { hook_timeout: Duration::from_secs(1), circuit_failure_threshold: 2, circuit_cooldown: Duration::from_secs(30), + ..GatewayPluginPipelineConfig::default() }, ); @@ -1409,6 +1455,7 @@ mod tests { hook_timeout: Duration::from_millis(1), circuit_failure_threshold: 1, circuit_cooldown: Duration::from_secs(60), + ..GatewayPluginPipelineConfig::default() }, ); @@ -1448,6 +1495,7 @@ mod tests { hook_timeout: Duration::from_millis(1), circuit_failure_threshold: 1, circuit_cooldown: Duration::from_secs(60), + ..GatewayPluginPipelineConfig::default() }, ); @@ -1491,6 +1539,7 @@ mod tests { hook_timeout: Duration::from_secs(1), circuit_failure_threshold: 1, circuit_cooldown: Duration::from_millis(1), + ..GatewayPluginPipelineConfig::default() }, ); @@ -1528,6 +1577,7 @@ mod tests { hook_timeout: Duration::from_secs(1), circuit_failure_threshold: 2, circuit_cooldown: Duration::from_secs(30), + ..GatewayPluginPipelineConfig::default() }, ); diff --git a/src-tauri/src/gateway/proxy/errors.rs b/src-tauri/src/gateway/proxy/errors.rs index a0b59988..8db7c566 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, @@ -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![], ); } From 13141ca8b479175af7c3854ed2829181642a6f59 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Wed, 24 Jun 2026 11:22:32 +0800 Subject: [PATCH 073/244] fix(plugins): bound runtime artifact reads --- docs/plugins/examples/privacy-filter.md | 2 + docs/plugins/reference/declarative-rules.md | 4 ++ .../official_privacy_filter_runtime.rs | 46 +++++++++++++++++++ src-tauri/src/app/plugins/rule_runtime.rs | 40 +++++++++++++++- 4 files changed, 91 insertions(+), 1 deletion(-) diff --git a/docs/plugins/examples/privacy-filter.md b/docs/plugins/examples/privacy-filter.md index c2de3b9b..90975014 100644 --- a/docs/plugins/examples/privacy-filter.md +++ b/docs/plugins/examples/privacy-filter.md @@ -47,6 +47,8 @@ Provider request shapes: 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,仍可能看到原始输入。 +Official privacy filter rules are loaded under a 1 MiB host byte budget. Community plugins cannot use `native:privacyFilter`; community redaction plugins should use `declarativeRules` until WASM is fully lifecycle-managed. + 重要限制: 和 upstream 一样,Privacy Filter 是 irreversible redaction。它不会在 upstream processing 后把原始敏感值恢复到模型响应中。 diff --git a/docs/plugins/reference/declarative-rules.md b/docs/plugins/reference/declarative-rules.md index 3047a920..941dc5c5 100644 --- a/docs/plugins/reference/declarative-rules.md +++ b/docs/plugins/reference/declarative-rules.md @@ -174,6 +174,10 @@ Capture groups 使用 Rust regex replacement syntax: - Hook execution 受 gateway plugin timeout 约束。 - 当 target 无法解析为 JSON syntax 时,会跳过 invalid JSON targets。 +## Artifact Limits + +Declarative rule files are loaded under a host byte budget before JSON parsing and regex compilation. 0.62.3 limits each rule file to 256 KiB and rejects oversized rule files with `PLUGIN_RULE_FILE_TOO_LARGE`, then applies normal plugin failure and lifecycle handling. This keeps plugin package content from turning runtime load into an unbounded memory allocation. + ## 本地 Replay 兼容性 `create-aio-plugin replay` 为本地 fixtures 实现宿主支持的 v1.1 declarative rule subset。它刻意保持确定性,不执行 WASM、process plugins、network calls 或 host-only native engines。 diff --git a/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs b/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs index 5daa1da0..284f6022 100644 --- a/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs +++ b/src-tauri/src/app/plugins/official_privacy_filter_runtime.rs @@ -11,6 +11,8 @@ 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(Default)] pub(crate) struct OfficialPrivacyFilterRuntime { cache: Mutex>>, @@ -118,6 +120,17 @@ fn load_official_privacy_filter( )) })?; 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}", @@ -631,6 +644,15 @@ mod tests { } } + fn official_privacy_filter_plugin_detail_with_dir( + installed_dir: String, + config: serde_json::Value, + ) -> PluginDetail { + let mut plugin = official_privacy_filter_detail(config); + plugin.installed_dir = Some(installed_dir); + plugin + } + fn execute_official_privacy_filter_request( config: serde_json::Value, body: impl Into, @@ -655,6 +677,30 @@ mod tests { }) } + #[test] + fn official_privacy_filter_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 = official_privacy_filter_plugin_detail_with_dir( + dir.path().to_string_lossy().to_string(), + serde_json::json!({ "redactLogs": true }), + ); + + let err = load_official_privacy_filter(&plugin).expect_err("oversized rules should fail"); + + assert!(err.to_string().contains("privacy filter rule file exceeds")); + } + #[test] fn official_privacy_filter_redacts_phone_numbers_in_provider_request_shapes() { let executor = OfficialPrivacyFilterRuntime::default(); diff --git a/src-tauri/src/app/plugins/rule_runtime.rs b/src-tauri/src/app/plugins/rule_runtime.rs index 8f7d4454..73d09184 100644 --- a/src-tauri/src/app/plugins/rule_runtime.rs +++ b/src-tauri/src/app/plugins/rule_runtime.rs @@ -19,6 +19,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; pub(crate) const MAX_RULE_REGEX_PATTERN_BYTES: usize = 4 * 1024; +pub(crate) const MAX_RULE_FILE_BYTES: usize = 256 * 1024; const MAX_RULE_REGEX_COMPILED_BYTES: usize = 2 * 1024 * 1024; const MAX_RULES_PER_RUNTIME: usize = 256; @@ -371,7 +372,23 @@ fn load_rule_runtime(plugin: &PluginDetail) -> Result MAX_RULE_FILE_BYTES as u64 { + return Err(RuleRuntimeError::new( + "PLUGIN_RULE_FILE_TOO_LARGE", + format!("rule file exceeds {MAX_RULE_FILE_BYTES} bytes: {rule_path}"), + )); + } + let bytes = fs::read(&path).map_err(|err| { RuleRuntimeError::new( "PLUGIN_RULE_READ_FAILED", format!( @@ -998,6 +1015,10 @@ mod tests { } } + fn rule_plugin_detail(plugin_id: &str, installed_dir: String) -> PluginDetail { + rule_plugin(plugin_id, "1.0.0", installed_dir) + } + fn rule_plugin_with_updated_at( plugin_id: &str, version: &str, @@ -1034,6 +1055,23 @@ mod tests { .expect("write rule file"); } + #[test] + fn rule_runtime_rejects_rule_files_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("main.json"), + format!("{{\"rules\":[]}}{}", " ".repeat(MAX_RULE_FILE_BYTES + 1)), + ) + .expect("rule file"); + + let plugin = rule_plugin_detail("example.rules", dir.path().to_string_lossy().to_string()); + let err = load_rule_runtime(&plugin).expect_err("oversized rule file should be rejected"); + + assert_eq!(err.code(), "PLUGIN_RULE_FILE_TOO_LARGE"); + } + #[test] fn rule_plugin_runtime_replaces_regex_hits_at_json_path() { let runtime = RuleRuntime::from_value(json!({ From 65060d71400ac7ebe0ae6b820008dff77eea07e7 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Wed, 24 Jun 2026 11:56:04 +0800 Subject: [PATCH 074/244] fix(plugins): make lifecycle state writes transactional --- src-tauri/src/app/plugin_service.rs | 162 +++++++++++-------- src-tauri/src/infra/plugins/repository.rs | 183 +++++++++++++++++++++- 2 files changed, 274 insertions(+), 71 deletions(-) diff --git a/src-tauri/src/app/plugin_service.rs b/src-tauri/src/app/plugin_service.rs index 22326cac..7c40ed52 100644 --- a/src-tauri/src/app/plugin_service.rs +++ b/src-tauri/src/app/plugin_service.rs @@ -874,32 +874,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, - "signatureVerified": 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: PluginInstallSource::Local, + status: PluginStatus::Disabled, + installed_dir: Some(installed_dir.to_string_lossy().to_string()), + }, + )?; + let detail = repository::save_plugin_permissions_with_tx( + tx, + &plugin_id, + &[], + &requested_permissions, + )?; + append_audit_with_tx( + tx, + 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, + "signatureVerified": trust.signature_verified, + "developerMode": policy.developer_mode, + }), + )?; + Ok(detail) + })?; tracing::info!( plugin_id, version, @@ -1108,34 +1115,38 @@ pub(crate) fn update_plugin_from_local_package( .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, - "signatureVerified": trust.signature_verified, - "developerMode": policy.developer_mode, - }), - )?; + 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), + ¤t.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, @@ -1170,15 +1181,18 @@ pub(crate) fn rollback_plugin_to_version( format!("plugin version {version} install directory is unavailable"), )); } - 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 detail = repository::with_plugin_transaction(db, |tx| { + let detail = repository::update_plugin_manifest_with_tx(tx, manifest, installed_dir)?; + append_audit_with_tx( + tx, + Some(plugin_id.to_string()), + "plugin.rollback", + "high", + "Plugin rolled back", + serde_json::json!({ "version": version }), + )?; + Ok(detail) + })?; tracing::warn!(plugin_id, version, "plugin rolled back to previous version"); Ok(detail) } @@ -1632,6 +1646,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( diff --git a/src-tauri/src/infra/plugins/repository.rs b/src-tauri/src/infra/plugins/repository.rs index 778a3396..64386727 100644 --- a/src-tauri/src/infra/plugins/repository.rs +++ b/src-tauri/src/infra/plugins/repository.rs @@ -208,9 +208,44 @@ pub(crate) fn plugin_installed_dir_available(installed_dir: &str) -> bool { 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(&input.manifest, env!("CARGO_PKG_VERSION"))?; 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}"))?; @@ -273,7 +308,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( @@ -302,13 +337,30 @@ 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 { + validate_manifest(&manifest, env!("CARGO_PKG_VERSION"))?; let now = now_unix_seconds(); let plugin_id = manifest.id.clone(); let version = manifest.version.clone(); @@ -367,7 +419,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( @@ -408,7 +460,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}"))?; @@ -467,7 +539,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}"))?; @@ -498,7 +588,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( @@ -506,6 +596,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(); @@ -534,7 +638,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( @@ -892,6 +996,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(); From 5fddef5090406f4a363bd52e0eba98bf5d88aa5e Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Wed, 24 Jun 2026 12:23:18 +0800 Subject: [PATCH 075/244] test(plugins): add hardening release guards --- docs/plugins/developer-guide.md | 12 ++++ docs/plugins/runtime/README.md | 4 ++ package.json | 1 + src-tauri/src/gateway/plugins/context.rs | 29 ++++++-- src-tauri/src/gateway/plugins/pipeline.rs | 80 +++++++++++++++++++++++ src-tauri/src/infra/plugins/repository.rs | 2 +- 6 files changed, 123 insertions(+), 5 deletions(-) diff --git a/docs/plugins/developer-guide.md b/docs/plugins/developer-guide.md index 3406a812..6017087b 100644 --- a/docs/plugins/developer-guide.md +++ b/docs/plugins/developer-guide.md @@ -227,6 +227,18 @@ Claude 和 Codex/OpenAI Responses 的请求结构不同。插件应避免只适 新增权限会进入 pending,不会因为升级自动获得授权。更新后如果插件仍需要新增权限,用户必须重新授权后再获得对应能力。 +## 0.62.3 Runtime Hardening Boundary + +0.62.3 does not expand Plugin API v1. It hardens the host internals so the existing API can grow safely later: + +- `apiVersion` must stay on Plugin API major `1`. +- Runtime caches are retained only for the active plugin snapshot. +- Hook contexts and hook outputs are bounded. +- Oversized plugin artifacts are rejected before runtime parsing. +- Install, update, and rollback state writes are transactional. + +Run `pnpm check:plugin-hardening` before release branches that touch plugin runtime, hook context, SDK validation, or manifest documentation. + ## 配置表单 插件通过 `configSchema` 暴露用户配置。宿主负责渲染低代码表单并在保存前后校验。 diff --git a/docs/plugins/runtime/README.md b/docs/plugins/runtime/README.md index 4b7796e5..fe677c7c 100644 --- a/docs/plugins/runtime/README.md +++ b/docs/plugins/runtime/README.md @@ -18,3 +18,7 @@ 4. **Dispose**: clear runtime caches when plugins are disabled, updated, uninstalled, or when the gateway plugin snapshot is replaced. Community `declarativeRules` and official `native:privacyFilter` are the only runtimes wired into gateway execution. WASM remains policy-gated, and process runtime remains PoC-only until both are routed through the same lifecycle registry with memory, IO, timeout, and shutdown guarantees. + +## 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/package.json b/package.json index d6dd23c3..ce7d5a3a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "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:plugin-hardening": "pnpm check:plugin-api-contract && pnpm --filter @aio-coding-hub/plugin-sdk test && pnpm --filter @aio-coding-hub/plugin-sdk typecheck && cd src-tauri && cargo test plugin_ --lib", "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", diff --git a/src-tauri/src/gateway/plugins/context.rs b/src-tauri/src/gateway/plugins/context.rs index 6dc93a1c..828785f1 100644 --- a/src-tauri/src/gateway/plugins/context.rs +++ b/src-tauri/src/gateway/plugins/context.rs @@ -674,7 +674,7 @@ mod tests { } #[test] - fn visible_request_context_truncates_body_and_normalized_messages_by_budget() { + fn gateway_plugin_context_truncates_request_body_and_normalized_messages_by_budget() { let body = format!( "{{\"messages\":[{}]}}", (0..5) @@ -716,7 +716,7 @@ mod tests { } #[test] - fn visible_stream_and_log_context_truncate_by_budget() { + 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)), @@ -743,7 +743,29 @@ mod tests { } #[test] - fn visible_context_truncates_multibyte_text_without_replacement_characters() { + 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 { @@ -776,7 +798,6 @@ mod tests { log_bytes: 5, normalized_messages: 1, normalized_message_text_bytes: 8, - ..GatewayPluginContextBudget::default() }; let visible_request = diff --git a/src-tauri/src/gateway/plugins/pipeline.rs b/src-tauri/src/gateway/plugins/pipeline.rs index 8f47bd61..1394346a 100644 --- a/src-tauri/src/gateway/plugins/pipeline.rs +++ b/src-tauri/src/gateway/plugins/pipeline.rs @@ -1335,6 +1335,25 @@ mod tests { ); } + #[tokio::test(flavor = "current_thread")] + 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("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() { @@ -1432,6 +1451,67 @@ mod tests { })); } + #[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_times_out_and_opens_circuit_fail_open() { let executor = InMemoryGatewayPluginExecutor::new().with_request_async_handler( diff --git a/src-tauri/src/infra/plugins/repository.rs b/src-tauri/src/infra/plugins/repository.rs index 64386727..2bd31780 100644 --- a/src-tauri/src/infra/plugins/repository.rs +++ b/src-tauri/src/infra/plugins/repository.rs @@ -518,7 +518,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> { From 1c060ce059b7c6c57c5a5948f17850e7b7cad6ba Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Wed, 24 Jun 2026 13:36:21 +0800 Subject: [PATCH 076/244] fix(plugins): harden runtime mutation boundaries --- package.json | 2 +- src-tauri/src/app/plugin_service.rs | 178 ++++++++++--- src-tauri/src/app/plugins/rule_runtime.rs | 298 +++++++++++++++++++--- src-tauri/src/gateway/plugins/pipeline.rs | 105 ++++++++ 4 files changed, 511 insertions(+), 72 deletions(-) diff --git a/package.json b/package.json index ce7d5a3a..19ec6844 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "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:plugin-hardening": "pnpm check:plugin-api-contract && pnpm --filter @aio-coding-hub/plugin-sdk test && pnpm --filter @aio-coding-hub/plugin-sdk typecheck && cd src-tauri && cargo test plugin_ --lib", + "check:plugin-hardening": "pnpm check:plugin-api-contract && pnpm --filter @aio-coding-hub/plugin-sdk test && pnpm --filter @aio-coding-hub/plugin-sdk typecheck && cd src-tauri && cargo test --lib", "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", diff --git a/src-tauri/src/app/plugin_service.rs b/src-tauri/src/app/plugin_service.rs index 7c40ed52..7518d035 100644 --- a/src-tauri/src/app/plugin_service.rs +++ b/src-tauri/src/app/plugin_service.rs @@ -42,10 +42,19 @@ fn enabled_plugins_for_gateway_once(db: &crate::db::Db) -> AppResult, pub(crate) expected_checksum: Option, @@ -160,6 +169,23 @@ pub(crate) struct LocalPackageInstallPolicy { 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, +} + +impl Default for LocalPackageInstallPolicy { + fn default() -> Self { + Self { + expected_plugin_id: None, + expected_checksum: None, + signature: None, + public_key: None, + allow_unsigned: false, + developer_mode: false, + install_source: PluginInstallSource::Local, + remote_source_url: None, + } + } } #[derive(Debug, Clone)] @@ -879,7 +905,7 @@ pub(crate) fn install_plugin_from_local_package_with_policy( tx, repository::InsertPluginInput { manifest: extracted.manifest.clone(), - install_source: PluginInstallSource::Local, + install_source: policy.install_source, status: PluginStatus::Disabled, installed_dir: Some(installed_dir.to_string_lossy().to_string()), }, @@ -893,17 +919,21 @@ pub(crate) fn install_plugin_from_local_package_with_policy( append_audit_with_tx( tx, Some(plugin_id.clone()), - "plugin.installed", + install_audit_event_type(policy.install_source), "medium", - "Local plugin package installed", - serde_json::json!({ + install_audit_message(policy.install_source), + install_audit_details( + policy.install_source, + policy.remote_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) })?; @@ -967,6 +997,9 @@ 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 public_key = remote_package_trusted_public_key(db, source_url, &policy)?; let result = install_plugin_from_local_package_with_policy( @@ -976,43 +1009,22 @@ 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()), }, ) - .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); @@ -1283,12 +1295,50 @@ fn validate_local_package_install( policy: &LocalPackageInstallPolicy, ) -> AppResult { validate_manifest(&extracted.manifest, host_version)?; - validate_reserved_official_source(&extracted.manifest, PluginInstallSource::Local)?; + validate_reserved_official_source(&extracted.manifest, policy.install_source)?; 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>, + 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()), + ); + } + } + details +} + fn validate_reserved_official_source( manifest: &PluginManifest, install_source: PluginInstallSource, @@ -2212,6 +2262,51 @@ 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(); + grant_plugin_permissions( + &db, + "community.prompt-helper", + vec![ + "request.body.read".to_string(), + "request.body.write".to_string(), + ], + ) + .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 official_privacy_filter_install_uses_upstream_redaction_defaults() { let dir = tempfile::tempdir().unwrap(); @@ -3443,14 +3538,21 @@ INSERT INTO plugin_market_sources( .unwrap(); assert_eq!(detail.summary.plugin_id, "market.signed-risky"); + assert_eq!(detail.install_source, PluginInstallSource::Market); assert_eq!(detail.granted_permissions, Vec::::new()); assert_eq!(detail.pending_permissions, vec!["request.body.read"]); 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] diff --git a/src-tauri/src/app/plugins/rule_runtime.rs b/src-tauri/src/app/plugins/rule_runtime.rs index 73d09184..b5dd50bc 100644 --- a/src-tauri/src/app/plugins/rule_runtime.rs +++ b/src-tauri/src/app/plugins/rule_runtime.rs @@ -5,6 +5,10 @@ use super::runtime_lifecycle::PluginRuntimeCache; use crate::gateway::plugins::context::{ GatewayHookAction, GatewayHookResult, GatewayVisibleHookContext, }; +use crate::gateway::plugins::mutation::{ + DEFAULT_PLUGIN_MUTATION_BODY_BYTES, DEFAULT_PLUGIN_MUTATION_LOG_BYTES, + DEFAULT_PLUGIN_MUTATION_STREAM_BYTES, +}; use crate::gateway::plugins::permissions::GatewayPluginError; use crate::gateway::plugins::pipeline::{GatewayHookFuture, GatewayPluginExecutor}; use crate::plugins::{PluginDetail, PluginRuntime}; @@ -20,6 +24,8 @@ use std::sync::{Arc, Mutex}; pub(crate) const MAX_RULE_REGEX_PATTERN_BYTES: usize = 4 * 1024; pub(crate) const MAX_RULE_FILE_BYTES: usize = 256 * 1024; +pub(crate) const MAX_RULE_FILES_PER_RUNTIME: usize = 16; +pub(crate) const MAX_RULE_TOTAL_FILE_BYTES: usize = 1024 * 1024; const MAX_RULE_REGEX_COMPILED_BYTES: usize = 2 * 1024 * 1024; const MAX_RULES_PER_RUNTIME: usize = 256; @@ -179,9 +185,12 @@ impl RuleRuntime { result.reason = Some(message.clone()); } RuleAction::AppendMessage { role, content } => { - if let Some(next_body) = - append_chat_message(request_body.as_deref(), role, content)? - { + if let Some(next_body) = append_chat_message( + request_body.as_deref(), + role, + content, + output_limit_for_field(TargetField::RequestBody), + )? { request_body = Some(next_body); result.request_body = request_body.clone(); } @@ -361,7 +370,16 @@ fn load_rule_runtime(plugin: &PluginDetail) -> Result MAX_RULE_FILES_PER_RUNTIME { + return Err(RuleRuntimeError::new( + "PLUGIN_RULE_TOO_MANY_FILES", + format!( + "declarative rules runtime supports at most {MAX_RULE_FILES_PER_RUNTIME} files" + ), + )); + } + + let mut seen_rule_paths = HashSet::new(); for rule_path in rules { if rule_path.contains("..") || rule_path.starts_with('/') || rule_path.starts_with('\\') { return Err(RuleRuntimeError::new( @@ -372,6 +390,17 @@ fn load_rule_runtime(plugin: &PluginDetail) -> Result Result MAX_RULE_TOTAL_FILE_BYTES as u64 { + return Err(RuleRuntimeError::new( + "PLUGIN_RULE_TOTAL_FILES_TOO_LARGE", + format!("rule files exceed {MAX_RULE_TOTAL_FILE_BYTES} bytes in total"), + )); + } let bytes = fs::read(&path).map_err(|err| { RuleRuntimeError::new( "PLUGIN_RULE_READ_FAILED", @@ -663,21 +699,32 @@ fn apply_json_replace_batch_to_text( }; let mut matched = false; + let mut projected_len = current.len(); + let output_limit = output_limit_for_field(first.target.field); 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(); + if let Some(next) = + bounded_replace_all(&rule.regex, candidate, replacement, output_limit, &rule.id)? + { + account_projected_output_len( + &mut projected_len, + candidate.len(), + next.len(), + output_limit, + &rule.id, + )?; *candidate = next; matched = true; } + if projected_len > output_limit { + return Err(output_too_large_error(&rule.id)); + } } - }); + Ok(()) + })?; if matched { *current = serde_json::to_string(&root).map_err(|err| { @@ -697,7 +744,7 @@ fn apply_json_replace_batch_to_text( fn apply_rule_to_text( text: &mut Option, rule: &CompiledRule, - _output_field: OutputField, + output_field: OutputField, ) -> Result { let Some(current) = text.as_mut() else { return Ok(false); @@ -709,16 +756,28 @@ fn apply_rule_to_text( return Ok(false); }; let mut matched = false; + let mut projected_len = current.len(); + let output_limit = output_limit_for_output(output_field); 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(); + if let Some(next) = bounded_replace_all( + &rule.regex, + candidate, + replacement, + output_limit, + &rule.id, + )? { + account_projected_output_len( + &mut projected_len, + candidate.len(), + next.len(), + output_limit, + &rule.id, + )?; *candidate = next; matched = true; } - }); + Ok(()) + })?; if matched { *current = serde_json::to_string(&root).map_err(|err| { RuleRuntimeError::new( @@ -741,23 +800,115 @@ fn apply_rule_to_text( if rule.regex.is_match(candidate) { matched = true; } - }); + Ok(()) + })?; Ok(matched) } (None, RuleAction::Replace { replacement }) => { - if !rule.regex.is_match(current) { - return Ok(false); + if let Some(next) = bounded_replace_all( + &rule.regex, + current, + replacement, + output_limit_for_output(output_field), + &rule.id, + )? { + *current = next; + Ok(true) + } else { + Ok(false) } - *current = rule - .regex - .replace_all(current, replacement.as_str()) - .into_owned(); - Ok(true) } (None, _) => Ok(rule.regex.is_match(current)), } } +fn bounded_replace_all( + regex: &Regex, + text: &str, + replacement: &str, + limit: usize, + rule_id: &str, +) -> Result, RuleRuntimeError> { + let mut matched = false; + let mut out = String::new(); + let mut last_match_end = 0usize; + for caps in regex.captures_iter(text) { + let Some(matched_text) = caps.get(0) else { + continue; + }; + matched = true; + push_bounded( + &mut out, + &text[last_match_end..matched_text.start()], + limit, + rule_id, + )?; + let mut expanded = String::new(); + caps.expand(replacement, &mut expanded); + push_bounded(&mut out, &expanded, limit, rule_id)?; + last_match_end = matched_text.end(); + } + if !matched { + return Ok(None); + } + push_bounded(&mut out, &text[last_match_end..], limit, rule_id)?; + Ok(Some(out)) +} + +fn push_bounded( + out: &mut String, + value: &str, + limit: usize, + rule_id: &str, +) -> Result<(), RuleRuntimeError> { + if out.len().saturating_add(value.len()) > limit { + return Err(output_too_large_error(rule_id)); + } + out.push_str(value); + Ok(()) +} + +fn account_projected_output_len( + projected_len: &mut usize, + previous_len: usize, + next_len: usize, + limit: usize, + rule_id: &str, +) -> Result<(), RuleRuntimeError> { + if next_len > previous_len { + *projected_len = projected_len.saturating_add(next_len - previous_len); + if *projected_len > limit { + return Err(output_too_large_error(rule_id)); + } + } else { + *projected_len = projected_len.saturating_sub(previous_len - next_len); + } + Ok(()) +} + +fn output_too_large_error(rule_id: &str) -> RuleRuntimeError { + RuleRuntimeError::new( + "PLUGIN_OUTPUT_TOO_LARGE", + format!("rule {rule_id} output exceeds plugin mutation budget"), + ) +} + +fn output_limit_for_output(output_field: OutputField) -> usize { + match output_field { + OutputField::RequestBody | OutputField::ResponseBody => DEFAULT_PLUGIN_MUTATION_BODY_BYTES, + OutputField::StreamChunk => DEFAULT_PLUGIN_MUTATION_STREAM_BYTES, + OutputField::LogMessage => DEFAULT_PLUGIN_MUTATION_LOG_BYTES, + } +} + +fn output_limit_for_field(field: TargetField) -> usize { + match field { + TargetField::RequestBody | TargetField::ResponseBody => DEFAULT_PLUGIN_MUTATION_BODY_BYTES, + TargetField::StreamChunk => DEFAULT_PLUGIN_MUTATION_STREAM_BYTES, + TargetField::LogMessage => DEFAULT_PLUGIN_MUTATION_LOG_BYTES, + } +} + fn parse_json_or_skip(text: &str) -> Result, RuleRuntimeError> { #[cfg(test)] RULE_RUNTIME_TEST_JSON_PARSE_COUNT.with(|count| { @@ -778,10 +929,14 @@ fn append_chat_message( request_body: Option<&str>, role: &str, content: &str, + limit: usize, ) -> Result, RuleRuntimeError> { let Some(request_body) = request_body else { return Ok(None); }; + if request_body.len().saturating_add(content.len()) > limit { + return Err(output_too_large_error("appendMessage")); + } let Some(mut root) = parse_json_or_skip(request_body)? else { return Ok(None); }; @@ -792,39 +947,48 @@ fn append_chat_message( "role": role, "content": content, })); - serde_json::to_string(&root).map(Some).map_err(|err| { + let output = serde_json::to_string(&root).map_err(|err| { RuleRuntimeError::new( "PLUGIN_RULE_INVALID_OUTPUT", format!("failed to serialize appended chat message: {err}"), ) - }) + })?; + if output.len() > limit { + return Err(output_too_large_error("appendMessage")); + } + Ok(Some(output)) } -fn apply_to_json_strings_mut(value: &mut Value, path: &[JsonPathSegment], f: &mut F) +fn apply_to_json_strings_mut( + value: &mut Value, + path: &[JsonPathSegment], + f: &mut F, +) -> Result<(), RuleRuntimeError> where - F: FnMut(&mut String), + F: FnMut(&mut String) -> Result<(), RuleRuntimeError>, { if path.is_empty() { if let Value::String(value) = value { - f(value); + f(value)?; } - return; + return Ok(()); } match &path[0] { JsonPathSegment::Key(key) => { if let Some(next) = value.get_mut(key) { - apply_to_json_strings_mut(next, &path[1..], f); + 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); + apply_to_json_strings_mut(item, &path[1..], f)?; } } } } + Ok(()) } fn parse_json_path(path: &str) -> Result, RuleRuntimeError> { @@ -1072,6 +1236,40 @@ mod tests { assert_eq!(err.code(), "PLUGIN_RULE_FILE_TOO_LARGE"); } + #[test] + fn rule_runtime_rejects_too_many_rule_files_before_loading() { + let dir = tempfile::tempdir().expect("temp plugin dir"); + let mut plugin = rule_plugin_detail( + "example.too-many-rules", + dir.path().to_string_lossy().to_string(), + ); + plugin.manifest.runtime = PluginRuntime::DeclarativeRules { + rules: (0..=MAX_RULE_FILES_PER_RUNTIME) + .map(|index| format!("rules/{index}.json")) + .collect(), + }; + + let err = load_rule_runtime(&plugin).expect_err("too many rule files should be rejected"); + + assert_eq!(err.code(), "PLUGIN_RULE_TOO_MANY_FILES"); + } + + #[test] + fn rule_runtime_rejects_duplicate_rule_files_before_loading() { + let dir = tempfile::tempdir().expect("temp plugin dir"); + let mut plugin = rule_plugin_detail( + "example.duplicate-rules", + dir.path().to_string_lossy().to_string(), + ); + plugin.manifest.runtime = PluginRuntime::DeclarativeRules { + rules: vec!["rules/main.json".to_string(), "rules/main.json".to_string()], + }; + + let err = load_rule_runtime(&plugin).expect_err("duplicate rule files should be rejected"); + + assert_eq!(err.code(), "PLUGIN_RULE_DUPLICATE_FILE"); + } + #[test] fn rule_plugin_runtime_replaces_regex_hits_at_json_path() { let runtime = RuleRuntime::from_value(json!({ @@ -1104,6 +1302,40 @@ mod tests { assert!(!body.contains("sk-12345678")); } + #[test] + fn rule_plugin_runtime_rejects_replacement_output_over_budget_before_returning_result() { + let runtime = RuleRuntime::from_value(json!({ + "rules": [{ + "id": "expand-too-large", + "hook": "log.beforePersist", + "target": { "field": "log.message" }, + "match": { "regex": "secret" }, + "action": { + "kind": "replace", + "replacement": "x".repeat(DEFAULT_PLUGIN_MUTATION_LOG_BYTES + 1) + } + }] + })) + .expect("rules parse"); + let ctx = GatewayVisibleHookContext { + hook_name: "log.beforePersist".to_string(), + trace_id: "trace-rule-budget".to_string(), + request: GatewayVisibleRequestContext::default(), + response: GatewayVisibleResponseContext::default(), + stream: GatewayVisibleStreamContext::default(), + log: GatewayVisibleLogContext { + message: Some("secret".to_string()), + ..GatewayVisibleLogContext::default() + }, + }; + + let err = runtime + .execute(&ctx, &json!({})) + .expect_err("oversized replacement should fail inside runtime"); + + assert_eq!(err.code(), "PLUGIN_OUTPUT_TOO_LARGE"); + } + #[test] fn rule_runtime_batches_same_target_json_rewrites() { let runtime = RuleRuntime::from_value(json!({ diff --git a/src-tauri/src/gateway/plugins/pipeline.rs b/src-tauri/src/gateway/plugins/pipeline.rs index 1394346a..31d223d6 100644 --- a/src-tauri/src/gateway/plugins/pipeline.rs +++ b/src-tauri/src/gateway/plugins/pipeline.rs @@ -249,6 +249,7 @@ impl GatewayPluginPipeline { &plugin.granted_permissions, self.config.context_budget, ); + let truncation = VisibleTruncationState::from_context(&visible); 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, @@ -299,6 +300,7 @@ impl GatewayPluginPipeline { &plugin.granted_permissions, &result, self.config.mutation_budget, + &truncation, ) { self.record_failure(&plugin.summary.plugin_id); audit_events.push(audit_event( @@ -392,6 +394,7 @@ impl GatewayPluginPipeline { &plugin.granted_permissions, self.config.context_budget, ); + let truncation = VisibleTruncationState::from_context(&visible); let result = match tokio::time::timeout( self.config.hook_timeout, self.executor.execute_response_hook(plugin, visible), @@ -423,6 +426,7 @@ impl GatewayPluginPipeline { &plugin.granted_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())); @@ -501,6 +505,7 @@ impl GatewayPluginPipeline { &plugin.granted_permissions, self.config.context_budget, ); + let truncation = VisibleTruncationState::from_context(&visible); let result = match tokio::time::timeout( self.config.hook_timeout, self.executor.execute_stream_hook(plugin, visible), @@ -532,6 +537,7 @@ impl GatewayPluginPipeline { &plugin.granted_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())); @@ -605,6 +611,7 @@ impl GatewayPluginPipeline { &plugin.granted_permissions, self.config.context_budget, ); + let truncation = VisibleTruncationState::from_context(&visible); let result = match tokio::time::timeout( self.config.hook_timeout, self.executor.execute_log_hook(plugin, visible), @@ -636,6 +643,7 @@ impl GatewayPluginPipeline { &plugin.granted_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())); @@ -782,7 +790,9 @@ fn enforce_hook_result_with_budget( 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); } @@ -800,6 +810,51 @@ fn enforce_hook_result_with_budget( enforce_descriptor_permissions_with_budget(descriptor, permissions, result, budget) } +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()) @@ -1512,6 +1567,56 @@ mod tests { })); } + #[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( From 7608668f4ca5441dc97fbd5f67ba75a3c9ab6685 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Thu, 25 Jun 2026 16:45:42 +0800 Subject: [PATCH 077/244] docs: plan plugin observability replay publishing --- ...-observability-replay-publishing-design.md | 411 ++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-aio-coding-hub-plugin-observability-replay-publishing-design.md diff --git a/docs/superpowers/specs/2026-06-25-aio-coding-hub-plugin-observability-replay-publishing-design.md b/docs/superpowers/specs/2026-06-25-aio-coding-hub-plugin-observability-replay-publishing-design.md new file mode 100644 index 00000000..7c557ba2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-aio-coding-hub-plugin-observability-replay-publishing-design.md @@ -0,0 +1,411 @@ +# aio-coding-hub Plugin Observability, Replay, and Publishing Design + +日期:2026-06-25 + +## Summary + +本阶段是插件生态从“能安装、能运行”走向“能诊断、能复现、能发布”的产品化阶段。当前分支已经完成 Plugin API v1、Gateway-first hook、声明式规则运行时、生命周期预检、远程包安装、rollback、quarantine、runtime hardening 和基础开发工具。下一步不应急着扩大插件权限,也不应引入新的高风险 runtime,而是把现有能力串成完整闭环。 + +核心目标是让插件作者和宿主用户都能回答四个问题: + +1. 插件这次到底有没有运行? +2. 如果运行异常,异常发生在哪个 plugin、hook、runtime、策略或预算边界? +3. 能不能从真实 trace 导出最小 fixture,在开发者工具里复现? +4. 插件如何从示例、校验、打包、签名、市场索引到 GUI 安装形成稳定发布路径? + +本设计保持 Plugin API v1 外部兼容,不改变 `plugin.json` v1 shape,不新增 Provider Plugin API,不开放 JS/TS/WebView runtime,也不把 Tauri2 桌面 GUI 变成浏览器式插件容器。 + +## Current State + +当前插件系统已经具备较强基础: + +- `docs/plugin-manifest-v1.md`、`docs/plugins/plugin-api-v1-contract.json`、Rust domain 和 `@aio-coding-hub/plugin-sdk` 已经稳定 Plugin API v1 contract。 +- Active hooks 已覆盖 gateway request、response、stream、error 和 log-before-persist 的核心链路。 +- Reserved hooks 和 reserved permissions 已被拒绝,`plugin.storage`、`network.fetch`、`file.read/write`、`secret.read` 等能力没有开放。 +- 主力社区 runtime 是 `declarativeRules`;WASM runtime 仍受 host policy gate 控制;process runtime 仍是 PoC;第三方 native runtime 不开放。 +- `official.privacy-filter` 已迁入插件体系,并使用 host-owned native implementation。 +- `src-tauri/src/gateway/plugins/pipeline.rs` 已负责 hook 执行、failure policy、audit event、runtime failure、budget 和 circuit behavior。 +- `src-tauri/src/commands/request_logs.rs` 已提供 `request_log_get_by_trace_id` 和 `request_attempt_logs_by_trace_id`。 +- `src-tauri/src/commands/plugins.rs` 已提供 audit logs、market index parsing、remote install、local install/update/rollback/quarantine 等命令入口。 +- `src-tauri/src/infra/plugins/market.rs` 已支持 market listing、兼容性、撤销、更新状态、checksum 和 signed index verification。 +- `src/pages/PluginsPage.tsx` 已具备插件列表、详情、安装预检、更新 diff、启用/禁用/卸载、授权、配置、rollback、quarantine/revoked、lifecycle panel、runtime observability 基础展示。 +- `packages/create-aio-plugin` 已提供 scaffold、doctor、validate/strict、replay/explain、pack、sign、verify。 + +主要缺口不是“没有插件 API”,而是以下能力还没有成为一等产品模型: + +- 插件运行诊断仍主要分散在 audit logs、runtime failures、trace id 和 UI 文案中,缺少结构化 hook execution report。 +- request trace 和 create-aio-plugin replay 之间没有宿主导出的标准 fixture 桥接。 +- Rust 宿主声明式规则运行时和 TypeScript replay/explain 之间缺少长期 parity guard。 +- 官方示例插件体系不足,当前示例主要围绕 privacy filter,无法覆盖 prompt helper、response guard、日志脱敏、Claude/Codex request shape 等常见路径。 +- market index、remote install、checksum/signature 后端能力已有,但市场源配置、listing 展示、安装/更新状态、发布 metadata 还没有形成 GUI 和 CLI 的完整发布闭环。 + +## Goals + +本阶段必须交付: + +1. 结构化插件运行诊断模型,让宿主能稳定解释每次 hook 执行结果。 +2. Trace replay fixture 导出能力,让真实请求日志可以转化为开发者工具可复现输入。 +3. Host/runtime 与 devtools replay 的一致性测试和漂移防线。 +4. 一组官方示例插件,覆盖真实开发路径而不是只展示 manifest 字段。 +5. 插件市场和发布流程的产品化最小闭环。 +6. 对用户文档、开发者文档、发布文档和验收测试进行同步更新。 + +## Non-Goals + +本阶段不做: + +- 不改变 Plugin API v1 外部字段形状。 +- 不引入 Plugin API v2。 +- 不开放 Provider Plugin API。 +- 不开放 `plugin.storage`、`network.fetch`、`file.read`、`file.write`、`secret.read`。 +- 不开放 JS、TypeScript、WebView/browser 插件 runtime。 +- 不默认开放 marketplace WASM 执行。 +- 不开放第三方 native runtime。 +- 不做账号体系、评分、评论、推荐、支付或远程运营后台。 +- 不做自动后台静默更新。 +- 不让插件控制 provider selection、failover、OAuth、token counting、session binding。 +- 不在运行诊断中保存完整敏感 payload;诊断只保存有界摘要、状态、原因和 trace 关联。 + +## Product Direction + +### 1. 插件运行诊断 + +用户在插件详情页应该能看到“最近运行了什么”,而不是只看到一串 audit event。诊断视图应该按 plugin 和 hook 展示: + +- 最近成功、失败、跳过、阻断次数。 +- 最近一次运行时间、耗时和 trace id。 +- runtime kind。 +- failure kind、error code、failure policy。 +- 是否因为权限、runtime policy、hook mismatch、context budget、output budget、artifact limit 或 circuit breaker 被拒绝。 +- mutation 摘要,例如 body changed、headers changed、chunk changed、log redacted、blocked、warned,不展示完整原文。 + +这不是新的插件能力,而是宿主对已有执行行为的解释层。 + +### 2. Trace Replay + +用户在请求日志或插件诊断里看到 trace id 后,可以导出 replay fixture。fixture 的目标是“最小可复现”,不是完整请求归档。 + +导出的 fixture 至少包含: + +- trace id。 +- hook name。 +- provider/model/route 的必要元信息。 +- request body 或 response/log 片段的有界内容。 +- normalized messages,若宿主当时可生成。 +- plugin-relevant headers/meta 的安全子集。 +- attempts 摘要,用于解释 provider 路由和上游结果。 + +导出的 fixture 应能被 `create-aio-plugin replay --explain` 消费。无法导出的情况必须给出稳定错误原因,例如 trace 不存在、日志已被清理、body 不存在、内容超过导出上限、hook 不支持 replay。 + +### 3. Devtools 与宿主行为一致性 + +`create-aio-plugin replay --explain` 的价值取决于它和宿主真实 runtime 是否一致。后续需要建立共享 fixture 或 golden case: + +- 同一插件、同一 fixture、同一 hook,在 Rust 宿主和 TypeScript replay 中得到一致的 action、mutation summary、block/warn/pass 结果。 +- 对权限缺失、hook 不匹配、rule target 不兼容、context truncation、output budget、rule artifact 限制等边界给出一致诊断。 +- devtools 可以清楚标注“宿主支持、replay 不支持”的少数情况,避免假装完全等价。 + +这层是未来继续扩插件生态的维护成本控制点。没有 parity guard,API 不变也会因为实现漂移而变得难维护。 + +### 4. 示例插件 + +示例插件应该服务于真实插件作者,而不是只做文档展示。建议先提供三类示例: + +- `examples/prompt-helper`:在 `gateway.request.afterBodyRead` 或 `gateway.request.beforeSend` 追加提示词,展示 Claude 和 Codex/OpenAI Responses 两类 request shape。 +- `examples/redactor`:使用声明式规则对请求和日志做脱敏,展示 `request.body.read/write` 与 `log.redact`。 +- `examples/response-guard`:在 `gateway.response.after` 或 `gateway.response.chunk` 做告警、替换或阻断,展示响应侧 hook。 + +每个示例都必须能运行 `doctor`、`validate --strict`、`replay --explain` 和 `pack`。示例不依赖高风险权限,不使用 JS/WebView runtime,不要求 WASM 默认启用。 + +### 5. 插件市场和发布流程产品化 + +当前后端已经有 market index parsing 和 remote install,下一步要把它变成用户可操作的产品流程: + +- GUI 能保存一个默认 market index URL,并允许用户手动输入临时 URL 加载一次。 +- GUI 能加载 listing,并显示插件 id、name、latest version、risk labels、compatible、revoked、update available、install block reason。 +- 用户能从 listing 安装或更新插件。 +- GUI 展示 checksum、signature、source、trusted public key 相关状态。 +- revoked 或 incompatible listing 必须明确禁用安装动作。 +- `create-aio-plugin publish-check` 输出发布所需 metadata,包括 artifact path、sha256、signature 状态、manifest summary、compatibility 和 permissions。`pack/sign/verify` 保持现有职责,`publish-check` 负责把发布前检查结果整理成 market index 可引用的信息。 + +这仍是“最小市场闭环”,不做账号、评分、推荐和后台运营。 + +## Architecture + +### 1. 运行诊断模型 + +新增 host-owned 诊断模型,建议命名为 `PluginHookExecutionReport`。它从真实 pipeline 执行中产生,并可以由 audit/runtime failure 聚合得到。 + +建议字段: + +```text +PluginHookExecutionReport + id + traceId + pluginId + hookName + runtimeKind + status + startedAtMs + durationMs + failureKind + errorCode + failurePolicy + circuitState + contextBudget + outputBudget + mutationSummary + replayable + replayExportReason +``` + +`status` 至少区分: + +- `completed` +- `failedOpen` +- `failedClosed` +- `skipped` +- `blocked` +- `budgetRejected` +- `policyRejected` +- `circuitOpen` + +`mutationSummary` 只描述变化类型、字段和大小,不保存完整敏感内容。 + +实现应增加轻量 `plugin_hook_execution_reports` repository/table,并继续保留现有 audit logs 作为 append-only 证据。这样 GUI 可以按 plugin、hook、trace、status 做稳定查询,不需要解析 audit details JSON;audit logs 仍负责生命周期和审计语义。关键原则是:pipeline 是事实来源,GUI 不自行推断 hook 是否成功。 + +### 2. Trace Fixture Exporter + +新增 host-owned exporter,把 request logs、attempt logs 和 plugin execution report 合并为 devtools fixture。 + +建议模型: + +```text +PluginReplayFixture + schemaVersion + source + appVersion + traceId + exportedAtMs + hookName + request + method + path + provider + model + headers + body + normalizedMessages + response + status + headers + body + chunks + log + body + attempts + notes +``` + +不同 hook 只填充必要字段: + +- request hook fixture 主要填 request。 +- response hook fixture 主要填 request meta 和 response。 +- stream chunk fixture 主要填当前 chunk 和有界窗口。 +- log hook fixture 主要填 log-before-persist 所需字段。 + +导出命令放在插件命令边界中,命名为 `plugin_export_replay_fixture`。它以 `traceId`、`hookName` 和可选 `pluginId` 为输入,返回 fixture JSON 或稳定错误码。request log 命令继续负责原始日志查询,插件命令负责面向插件开发者的可复现导出。 + +### 3. Devtools Parity Layer + +`packages/create-aio-plugin` 继续保持轻量,不把整个 Rust runtime 复制一遍。它应该共享 contract、fixtures 和 golden expectations: + +- contract metadata 继续约束 hooks、permissions、runtime、failure policies。 +- replay fixture schema 固定为 JSON,并在 SDK/devtools 测试中校验。 +- Rust runtime tests 和 TypeScript replay tests 读取同类 fixture,断言相同的 explain summary。 +- 对 devtools 无法模拟的宿主行为,输出 `unsupportedInReplay` 或等价 warning。 + +这层的重点是防漂移,不是创造第二套插件运行时。 + +### 4. GUI Surfaces + +GUI 保持 Tauri2 桌面应用体验,不引入内嵌浏览器容器。 + +建议新增或整理以下区域: + +- 插件详情页的“运行诊断”区域:按 hook 显示最近执行报告、失败原因、trace id、导出 replay fixture 动作。 +- 请求日志详情中的“插件影响”区域:展示这个 trace 经过了哪些插件,以及每个插件的结果。 +- 插件市场区域:加载 market index、展示 listing 状态、安装/更新。 +- 发布信息展示:安装或更新前继续复用 preview/diff,同时展示 market source、checksum、signature。 + +GUI 只调用后端 command 返回的结构化结果,不在前端重新实现兼容性、权限、签名或 runtime policy 判断。 + +### 5. Documentation and Examples + +文档需要同步更新: + +- `docs/plugins/developer-guide.md`:加入 trace export -> replay -> fix -> pack 的开发路径。 +- `docs/plugins/reference/publishing.md`:加入发布 metadata、market index、checksum/signature、revoked/incompatible 状态。 +- `docs/plugins/examples/README.md`:列出官方示例插件和各自覆盖的 hook/permission/fixture。 +- `docs/plugins/reference/hooks.md`:补充每个 hook 的 replay 支持状态。 + +示例插件应当作为测试资产使用,避免文档和真实工具链分离。 + +## Data Flow + +### Runtime Diagnosis + +```text +gateway request/response/log + -> GatewayPluginPipeline + -> runtime executor + -> PluginHookExecutionReport + -> audit/runtime failure persistence + -> plugin command/query + -> React Query + -> PluginsPage diagnosis panel +``` + +### Trace Replay + +```text +request_logs + attempt_logs + plugin reports + -> replay fixture exporter + -> fixture JSON + -> create-aio-plugin replay --explain + -> explain summary + -> plugin author fixes rules +``` + +### Publishing + +```text +plugin directory + -> doctor / validate --strict / replay --explain + -> pack + -> sign / verify + -> publish metadata + -> market index + -> GUI market listing + -> install preview / update diff + -> install or update +``` + +## Error Handling + +运行诊断必须使用稳定 machine-readable code,不只依赖自由文本。建议错误类别包括: + +- `PLUGIN_HOOK_TIMEOUT` +- `PLUGIN_RUNTIME_DISABLED` +- `PLUGIN_RUNTIME_POLICY_REJECTED` +- `PLUGIN_PERMISSION_DENIED` +- `PLUGIN_CONTEXT_BUDGET_EXCEEDED` +- `PLUGIN_OUTPUT_BUDGET_EXCEEDED` +- `PLUGIN_RULE_ARTIFACT_LIMIT_EXCEEDED` +- `PLUGIN_REPLAY_UNAVAILABLE` +- `PLUGIN_MARKET_INDEX_INVALID` +- `PLUGIN_MARKET_SIGNATURE_INVALID` +- `PLUGIN_MARKET_LISTING_REVOKED` +- `PLUGIN_MARKET_LISTING_INCOMPATIBLE` + +导出 replay fixture 时,错误必须明确是“无法复现”还是“当前 hook 不支持复现”。这两个情况对用户行动不同:前者可能需要换 trace,后者需要改工具支持范围。 + +## Compatibility + +本阶段保持以下兼容性: + +- Plugin API v1 manifest shape 不变。 +- `apiVersion` major 仍必须为 `1`。 +- 现有插件包继续可安装和运行。 +- `declarativeRules` 是默认社区 runtime。 +- WASM、process、native 的公开策略不扩大。 +- 已有 lifecycle preview/diff/rollback/quarantine 行为继续有效。 +- 诊断和 replay fixture 是宿主/工具能力,不是插件可调用 API。 + +如果内部为了诊断新增数据库表或 command 返回字段,需要提供 migration 和前端空状态兼容。旧数据没有 execution report 时,GUI 应显示“无结构化诊断记录”,而不是报错。 + +## Testing Strategy + +### Rust Backend + +- Pipeline tests:成功、失败打开、失败关闭、timeout、circuit open、budget rejection、policy rejection 都产生正确 execution report。 +- Repository tests:execution report 可按 plugin、hook、trace 查询,limit 生效。 +- Command tests:诊断列表、trace fixture 导出、market listing install/update 状态返回稳定结构。 +- Exporter tests:request/response/stream/log hook 的 fixture 字段符合 schema,超限内容被拒绝或截断并记录原因。 +- Market tests:revoked、incompatible、checksum mismatch、signed index invalid、update available。 + +### TypeScript Devtools + +- Fixture schema tests:宿主导出的 fixture 能被 devtools 读取。 +- Replay parity tests:同一 declarative rule fixture 的 explain summary 与 Rust golden expectation 一致。 +- Publishing tests:`publish-check` 输出 metadata,`pack/sign/verify` 继续通过现有职责测试。 +- Example tests:官方示例能通过 doctor、validate strict、replay explain。 + +### Frontend + +- Plugins page tests:诊断区域展示 completed/failed/skipped/blocked/budget rejected。 +- Trace export action tests:可导出时显示动作,不可导出时展示原因。 +- Market UI tests:revoked/incompatible 禁用安装,update available 显示更新入口。 +- Empty state tests:没有诊断、没有 market source、没有 listing 时不误导用户。 + +### End-to-End Acceptance + +1. 安装示例 redactor 插件,发送命中请求,插件详情页显示 hook completed、trace id 和 mutation summary。 +2. 从该 trace 导出 fixture,用 `create-aio-plugin replay --explain` 得到同类 matched rule 和 mutation summary。 +3. 修改规则导致权限或 target 不匹配,宿主诊断和 devtools explain 都给出一致原因。 +4. 加载包含 revoked 和 incompatible 插件的 market index,GUI 禁用安装并展示原因。 +5. 对一个可更新插件执行 market update,安装前仍显示 update diff、checksum/signature/source。 + +## Acceptance Criteria + +- 用户可以从插件详情页看到结构化 hook 执行结果,不需要阅读原始 audit JSON。 +- 用户可以从支持的 trace 导出 replay fixture,并用 devtools replay 复现声明式规则行为。 +- Rust 宿主和 TypeScript devtools 对核心 declarative rules fixture 有 parity 测试。 +- 官方示例插件至少覆盖 prompt helper、redactor、response guard 三类场景。 +- 示例插件覆盖 Claude 与 Codex/OpenAI Responses request shape。 +- GUI 能展示 market listing,并正确处理 compatible、revoked、update available 和 install block reason。 +- 发布文档说明 pack、checksum、signature、market index、revocation、update metadata。 +- 没有新增高风险插件 API 或 runtime。 +- Plugin API v1 contract 文档、SDK 和宿主校验仍保持一致。 + +## Implementation Boundary + +建议按以下顺序实现: + +1. 运行诊断模型和查询能力。 +2. Trace replay fixture exporter。 +3. Devtools fixture schema 和 parity tests。 +4. 官方示例插件和示例测试。 +5. GUI 诊断区和 trace export 动作。 +6. GUI market listing 和发布 metadata 工具/文档。 + +这个顺序让后续每一步都能复用前一步的证据链。市场和示例可以先做最小可用,但不应早于诊断和 replay,否则插件出问题时仍然缺乏排查闭环。 + +## Risks and Mitigations + +- 风险:诊断记录过多导致数据库增长。 + - 缓解:限制每个插件或全局保留数量,按时间和 limit 查询;默认只保存摘要。 +- 风险:replay fixture 泄露敏感内容。 + - 缓解:导出前使用有界内容、摘要和用户显式动作;不默认批量导出。 +- 风险:devtools 和 host parity 维护成本过高。 + - 缓解:只对 declarativeRules 的核心行为做 golden parity,WASM/process/native 标记为不支持或 policy-gated。 +- 风险:市场产品化扩大信任边界。 + - 缓解:market 只是 listing 和受约束安装入口,真实安装仍重新执行 checksum、signature、compatibility、runtime policy、permission policy。 +- 风险:示例插件变成另一套维护负担。 + - 缓解:示例纳入 doctor/validate/replay/pack 测试,作为工具链验收资产。 + +## Fixed Design Decisions + +本设计已固定以下决定: + +- 不改 Plugin API v1。 +- 先做诊断和 replay,再做示例和市场。 +- replay fixture 是宿主/开发工具能力,不是插件可调用 API。 +- market 产品化保持最小闭环,不做账号和运营后台。 + +后续 implementation plan 必须按这些决定拆分任务: + +- 运行诊断新增轻量 `plugin_hook_execution_reports` repository/table,不让前端解析 audit JSON。 +- Trace fixture 导出命令命名为 `plugin_export_replay_fixture`。 +- 市场产品化第一版支持一个本地保存的默认 market index URL,并支持临时 URL 加载。 +- 发布侧新增 `create-aio-plugin publish-check`,不把发布检查塞进 `pack` 的主职责里。 From 3edb3b917b6ec91fe5082308a442798bc10819c4 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Thu, 25 Jun 2026 17:32:50 +0800 Subject: [PATCH 078/244] docs: add plugin observability replay publishing plan --- ...-plugin-observability-replay-publishing.md | 778 ++++++++++++++++++ 1 file changed, 778 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-aio-coding-hub-plugin-observability-replay-publishing.md diff --git a/docs/superpowers/plans/2026-06-25-aio-coding-hub-plugin-observability-replay-publishing.md b/docs/superpowers/plans/2026-06-25-aio-coding-hub-plugin-observability-replay-publishing.md new file mode 100644 index 00000000..fc234fe1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-aio-coding-hub-plugin-observability-replay-publishing.md @@ -0,0 +1,778 @@ +# aio-coding-hub Plugin Observability, Replay, and Publishing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the plugin observability, trace replay, developer tooling parity, example plugin, and market/publishing productization work without changing Plugin API v1. + +**Architecture:** Keep Plugin API v1 and the existing gateway hook contract stable. Add host-owned runtime diagnosis, replay fixture export, and a lightweight market/publishing UI around the current plugin pipeline and `create-aio-plugin` tooling. Rust stays the source of truth for runtime facts; TypeScript tooling mirrors the contract and replay fixtures; the GUI only renders structured host results. + +**Tech Stack:** Rust, Tauri 2 commands, SQLite via rusqlite/r2d2, Specta generated bindings, React 19, TanStack Query, Vitest, Cargo tests, Node.js, Markdown docs. + +--- + +## Scope Boundaries + +- Do not change the public Plugin API v1 manifest shape. +- Do not add Plugin API v2. +- Do not expose Provider Plugin API. +- Do not open `plugin.storage`, `network.fetch`, `file.read`, `file.write`, or `secret.read`. +- Do not open JS/TS/WebView/browser plugin runtimes. +- Do not enable third-party native runtime. +- Do not default-enable marketplace WASM execution. +- Do not add ratings, reviews, recommendation, payment, or operator backend features. +- Keep replay fixtures and diagnostics host-owned; plugins must not call them directly. +- Preserve current external behavior for existing local install, remote install, rollback, quarantine, and official plugin flows. + +## File Structure + +- Modify: `src-tauri/src/domain/plugins.rs` + - Add plugin runtime diagnosis DTOs and replay fixture DTOs that Specta can export. +- Modify: `src-tauri/src/infra/plugins/repository.rs` + - Add repository helpers for hook execution reports and market source access. +- Create: `src-tauri/src/infra/plugins/runtime_reports.rs` + - Own the hook execution report repository and query helpers. +- Create: `src-tauri/src/infra/plugins/replay_export.rs` + - Build trace-to-fixture export models from request logs, attempt logs, and plugin reports. +- Modify: `src-tauri/src/commands/plugins.rs` + - Add plugin diagnosis, replay export, and market source command entry points. +- Modify: `src-tauri/src/commands/request_logs.rs` + - Reuse existing trace and attempt log access for replay export inputs. +- Modify: `src-tauri/src/commands/registry.rs` + - Register the new plugin commands for runtime and Specta export. +- Modify: `src-tauri/src/gateway/plugins/pipeline.rs` + - Record structured execution reports from hook execution outcomes. +- Modify: `src-tauri/src/gateway/plugins/context.rs` + - Keep bounded visible context and surface truncation data needed by diagnosis and replay export. +- Modify: `src-tauri/src/gateway/plugins/mutation.rs` + - Reuse output mutation budgets in execution reporting. +- Modify: `src-tauri/src/app/plugin_service.rs` + - Expose detail/query helpers for reports and replay export support. +- Modify: `src-tauri/src/app/plugins/mod.rs` + - Export any new runtime report helpers used by the gateway or services. +- Modify: `src-tauri/src/infra/db/migrations/ensure.rs` + - Add the new plugin execution report table and any supporting indexes. +- Modify: `src-tauri/src/infra/db/migrations/v33_to_v34.rs` + - Seed the new plugin report table on fresh installs and upgrade existing databases. +- Modify: `src/services/plugins.ts` + - Add IPC wrappers and TypeScript types for diagnosis, replay export, and market source handling. +- Modify: `src/query/keys.ts` + - Add query keys for plugin runtime reports and market source data. +- Modify: `src/query/plugins.ts` + - Add React Query hooks and mutations for the new plugin operations. +- Modify: `src/pages/PluginsPage.tsx` + - Integrate the new observability panel, replay export action, and market source surfaces. +- Create: `src/pages/plugins/PluginRuntimeReportsPanel.tsx` + - Render structured hook execution reports and replay export actions. +- Create: `src/pages/plugins/PluginMarketPanel.tsx` + - Render market source loading, listing state, trust, compatibility, and install/update actions. +- Modify: `src/pages/plugins/PluginLifecyclePanel.tsx` + - Keep lifecycle state visible alongside the new observability data. +- Modify: `src/pages/plugins/PluginInstallPreviewDialog.tsx` + - Keep install preview behavior aligned with market and replay flows. +- Modify: `src/pages/plugins/PluginUpdatePreviewDialog.tsx` + - Keep update diff behavior aligned with market and replay flows. +- Modify: `src/pages/__tests__/PluginsPage.test.tsx` + - Cover the new observability and market surfaces. +- Modify: `src/pages/plugins/__tests__/PluginConfigSchemaForm.test.tsx` + - Keep existing plugin details rendering stable after new sections land. +- Modify: `packages/create-aio-plugin/src/devtools.ts` + - Add fixture parsing, replay explain parity helpers, and publish-check output. +- Modify: `packages/create-aio-plugin/src/scaffold.ts` + - Add or refine official example scaffolds as needed. +- Modify: `packages/create-aio-plugin/src/scaffold.test.ts` + - Add CLI and replay parity tests for the new devtools behavior. +- Modify: `packages/create-aio-plugin/src/cli.ts` + - Register the new CLI command if command routing changes. +- Create: `packages/create-aio-plugin/src/fixtures.ts` + - Hold reusable replay fixture and publish-check test helpers if the devtools file becomes too large. +- Modify: `docs/plugins/developer-guide.md` + - Document the trace export -> replay -> fix -> pack workflow. +- Modify: `docs/plugins/examples/README.md` + - Document the new official example plugins. +- Modify: `docs/plugins/examples/privacy-filter.md` + - Keep the official example docs aligned with the host/runtime boundary. +- Modify: `docs/plugins/reference/hooks.md` + - Document replay support and diagnosis visibility per hook. +- Modify: `docs/plugins/reference/publishing.md` + - Document replay fixtures, publish-check, market sources, and trust flow. +- Modify: `docs/plugins/reference/README.md` + - Point readers to the new observability and publishing docs. +- Modify: `docs/plugins/runtime/README.md` + - Keep runtime lifecycle wording aligned with the host-owned lifecycle boundary. + +## Task 1: Add Structured Plugin Runtime Reports + +**Files:** +- Modify: `src-tauri/src/domain/plugins.rs` +- Create: `src-tauri/src/infra/plugins/runtime_reports.rs` +- Modify: `src-tauri/src/infra/plugins/repository.rs` +- Modify: `src-tauri/src/infra/db/migrations/ensure.rs` +- Create: `src-tauri/src/infra/db/migrations/v33_to_v34.rs` +- Modify: `src-tauri/src/gateway/plugins/pipeline.rs` +- Modify: `src-tauri/src/app/plugin_service.rs` +- Modify: `src-tauri/src/commands/plugins.rs` +- Modify: `src-tauri/src/commands/registry.rs` + +- [ ] **Step 1: Write the failing Rust repository and command tests** + +Add these tests to `src-tauri/src/infra/plugins/repository.rs` near the existing plugin repository tests: + +```rust +#[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 = crate::infra::plugins::runtime_reports::record_hook_execution_report( + &db, + crate::infra::plugins::runtime_reports::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: "declarativeRules".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 = crate::infra::plugins::runtime_reports::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"); +} +``` + +Add this test to `src-tauri/src/gateway/plugins/pipeline.rs` near the existing timeout/budget tests: + +```rust +#[tokio::test] +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"]); + plugin.manifest.hooks[0].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")) + })); +} +``` + +- [ ] **Step 2: Run the failing tests** + +Run: + +```bash +cd src-tauri && cargo test repository_records_and_lists_plugin_hook_execution_reports --lib && cargo test gateway_plugin_pipeline_records_runtime_report_for_fail_closed_timeout --lib +``` + +Expected: fail because the new runtime report repository and command plumbing do not exist yet. + +- [ ] **Step 3: Implement the new runtime report table, DTOs, and repository** + +In `src-tauri/src/domain/plugins.rs`, add a data-only DTO for execution reports and any matching summary/detail types that Specta can export. Keep the fields host-owned and append-only: + +```rust +#[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, +} +``` + +Create `src-tauri/src/infra/plugins/runtime_reports.rs` with repository functions that mirror the existing plugin repository style: insert, list, and list-by-trace/hook. Use the same `db_err!` and `now_unix_seconds()` helpers already used in `repository.rs`. + +Add the backing table in `src-tauri/src/infra/db/migrations/ensure.rs` with a dedicated `plugin_hook_execution_reports` table and indexes on `(plugin_id, created_at)` and `trace_id`. + +Add an incremental migration file `src-tauri/src/infra/db/migrations/v33_to_v34.rs` that creates the table for upgraded databases and records `schema_migrations`. + +- [ ] **Step 4: Route pipeline outcomes into the report repository** + +In `src-tauri/src/gateway/plugins/pipeline.rs`, add a small helper that converts each hook outcome into one report row. Record at least: + +```rust +status: "completed" | "failedOpen" | "failedClosed" | "skipped" | "blocked" | "budgetRejected" | "policyRejected" | "circuitOpen" +``` + +Use the existing audit events and failure policy logic as the source of truth. Do not invent new behavior in the report layer. The report should summarize what already happened in the pipeline, including timeout, budget rejection, and truncated-context rejection paths. + +- [ ] **Step 5: Expose runtime report commands** + +In `src-tauri/src/commands/plugins.rs`, add commands such as: + +```rust +#[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, +} + +#[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) +} +``` + +Register the new command in `src-tauri/src/commands/registry.rs` so `src/generated/bindings.ts` can be regenerated from the same source of truth. + +- [ ] **Step 6: Run the repository and command tests again** + +Run: + +```bash +cd src-tauri && cargo test repository_records_and_lists_plugin_hook_execution_reports --lib && cargo test gateway_plugin_pipeline_records_runtime_report_for_fail_closed_timeout --lib +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src-tauri/src/domain/plugins.rs src-tauri/src/infra/plugins/runtime_reports.rs src-tauri/src/infra/plugins/repository.rs src-tauri/src/infra/db/migrations/ensure.rs src-tauri/src/infra/db/migrations/v33_to_v34.rs src-tauri/src/gateway/plugins/pipeline.rs src-tauri/src/commands/plugins.rs src-tauri/src/commands/registry.rs +git commit -m "feat(plugins): add structured runtime reports" +``` + +## Task 2: Add Trace Replay Fixture Export + +**Files:** +- Create: `src-tauri/src/infra/plugins/replay_export.rs` +- Modify: `src-tauri/src/commands/request_logs.rs` +- Modify: `src-tauri/src/commands/plugins.rs` +- Modify: `src-tauri/src/commands/registry.rs` +- Modify: `src-tauri/src/domain/plugins.rs` +- Modify: `src/services/gateway/requestLogs.ts` +- Modify: `src/services/plugins.ts` +- Modify: `src/query/keys.ts` +- Modify: `src/query/plugins.ts` +- Create: `src/pages/plugins/PluginRuntimeReportsPanel.tsx` + +- [ ] **Step 1: Write a failing Rust exporter test** + +Add this test to `src-tauri/src/infra/plugins/replay_export.rs`: + +```rust +#[test] +fn export_replay_fixture_uses_trace_and_attempt_logs() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::db::init_for_tests(&dir.path().join("plugins.db")).unwrap(); + + // Seed request_logs, request_attempt_logs, and plugin reports for one trace. + // The fixture should include the trace id, hook name, normalized messages, and attempts. + + let fixture = export_plugin_replay_fixture( + &db, + 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!(!fixture.attempts.is_empty()); + assert!(fixture.request.body.is_some()); +} +``` + +- [ ] **Step 2: Run the exporter test and verify it fails** + +Run: + +```bash +cd src-tauri && cargo test export_replay_fixture_uses_trace_and_attempt_logs --lib +``` + +Expected: FAIL because the exporter does not exist yet. + +- [ ] **Step 3: Implement the replay fixture exporter** + +Create `src-tauri/src/infra/plugins/replay_export.rs` and keep it narrow: + +```rust +pub struct ExportPluginReplayFixtureInput { + pub trace_id: String, + pub hook_name: String, + pub plugin_id: Option, +} + +pub struct PluginReplayFixture { + pub schema_version: u32, + pub source: PluginReplayFixtureSource, + pub hook_name: String, + pub request: PluginReplayFixtureRequest, + pub response: PluginReplayFixtureResponse, + pub log: PluginReplayFixtureLog, + pub attempts: Vec, + pub notes: Vec, +} +``` + +Build the fixture from `request_log_get_by_trace_id` and `request_attempt_logs_by_trace_id`. Use the new runtime report table to include plugin-specific execution metadata when available. + +The exporter should return stable `PLUGIN_REPLAY_UNAVAILABLE` style errors when the trace is missing, the hook is unsupported, or the payload exceeds the host export limit. Do not silently drop required fields. + +- [ ] **Step 4: Expose the replay exporter through Tauri commands** + +In `src-tauri/src/commands/plugins.rs`, add: + +```rust +#[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, +} + +#[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) +} +``` + +Thread the command through `src-tauri/src/commands/registry.rs` so the generated bindings can pick it up. + +- [ ] **Step 5: Add frontend service/query support** + +In `src/services/gateway/requestLogs.ts`, add a replay-export helper that accepts a trace id and hook name and returns a fixture JSON shape from the new command. + +In `src/services/plugins.ts` and `src/query/plugins.ts`, add wrappers for listing runtime reports and exporting replay fixtures. + +In `src/pages/plugins/PluginRuntimeReportsPanel.tsx`, render the runtime report rows with: + +```tsx +// status, hook, trace id, duration, failure kind, and a replay export button +``` + +- [ ] **Step 6: Write React and service tests** + +Add tests to: + +```text +src/services/__tests__/plugins.test.ts +src/query/__tests__/plugins.test.tsx +src/pages/__tests__/PluginsPage.test.tsx +``` + +The tests should verify: + +- the replay export helper normalizes trace ids and hook names; +- the query layer invalidates and caches runtime reports; +- the page renders hook status rows and a replay export action; +- empty-state behavior stays readable when no reports exist. + +- [ ] **Step 7: Run the Rust, service, query, and page tests** + +Run: + +```bash +cd src-tauri && cargo test export_replay_fixture_uses_trace_and_attempt_logs --lib +pnpm test:unit src/services/__tests__/plugins.test.ts src/query/__tests__/plugins.test.tsx src/pages/__tests__/PluginsPage.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add src-tauri/src/infra/plugins/replay_export.rs src-tauri/src/commands/request_logs.rs src-tauri/src/commands/plugins.rs src-tauri/src/commands/registry.rs src-tauri/src/domain/plugins.rs src/services/gateway/requestLogs.ts src/services/plugins.ts src/query/keys.ts src/query/plugins.ts src/pages/plugins/PluginRuntimeReportsPanel.tsx src/pages/__tests__/PluginsPage.test.tsx src/services/__tests__/plugins.test.ts src/query/__tests__/plugins.test.tsx +git commit -m "feat(plugins): export trace replay fixtures" +``` + +## Task 3: Keep `create-aio-plugin` In Parity With The Host + +**Files:** +- Modify: `packages/create-aio-plugin/src/devtools.ts` +- Modify: `packages/create-aio-plugin/src/scaffold.ts` +- Modify: `packages/create-aio-plugin/src/scaffold.test.ts` +- Modify: `packages/create-aio-plugin/src/cli.ts` +- Create: `packages/create-aio-plugin/src/fixtures.ts` + +- [ ] **Step 1: Write failing tests for replay fixture parity and publish-check** + +Add tests to `packages/create-aio-plugin/src/scaffold.test.ts`: + +```ts +it("replay explain accepts exported host fixtures without changing the host contract shape", () => { + const fixture = { + schemaVersion: 1, + source: { + appVersion: "0.62.3", + traceId: "trace-replay-1", + exportedAtMs: 1_000, + }, + hookName: "gateway.request.afterBodyRead", + request: { + body: { messages: [{ role: "user", content: "SECRET_TOKEN" }] }, + normalizedMessages: [{ role: "user", content: "SECRET_TOKEN" }], + }, + response: null, + log: null, + attempts: [], + notes: [], + }; + + const result = replayHookExplain(files, "gateway.request.afterBodyRead", fixture); + + expect(result.pluginId).toBe("community.redactor"); + expect(result.outputKind).toMatch(/pass|replace|block|warn/); +}); + +it("publish-check emits package metadata needed by the market flow", () => { + 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, + }); +}); +``` + +- [ ] **Step 2: Run the tests and verify they fail** + +Run: + +```bash +pnpm --filter create-aio-plugin test -- src/scaffold.test.ts +``` + +Expected: FAIL because the new host fixture shape and `publish-check` helper are not implemented yet. + +- [ ] **Step 3: Add fixture-aware replay and `publish-check` helpers** + +In `packages/create-aio-plugin/src/devtools.ts`, add a small parser that accepts the exported host fixture schema and maps it to the existing declarative-rules replay engine without mutating the host contract shape. + +Also add: + +```ts +export function publishCheckPluginBytes( + bytes: Uint8Array, + input: { + checksum: string; + signature?: string | null; + publicKey?: string | null; + manifest: string; + } +): { + ok: boolean; + checksum: string; + signatureVerified: boolean; + manifestId: string; + version: string; + runtime: PluginManifest["runtime"]["kind"]; +} { + // Parse manifest, verify package checksum/signature, and summarize publish metadata. +} +``` + +Keep `pack`, `sign`, and `verify` as separate responsibilities. `publish-check` should summarize release metadata, not repack the package. + +- [ ] **Step 4: Add official example scaffolds if needed** + +Update `packages/create-aio-plugin/src/scaffold.ts` only if the current rule and wasm templates need a small official-example adjustment to cover prompt helper, redactor, or response guard fixtures. Keep the existing scaffold style; do not introduce a brand-new scaffold system. + +- [ ] **Step 5: Run the create-aio-plugin tests** + +Run: + +```bash +pnpm --filter create-aio-plugin test -- src/scaffold.test.ts +pnpm --filter create-aio-plugin typecheck +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/create-aio-plugin/src/devtools.ts packages/create-aio-plugin/src/scaffold.ts packages/create-aio-plugin/src/scaffold.test.ts packages/create-aio-plugin/src/cli.ts packages/create-aio-plugin/src/fixtures.ts +git commit -m "feat(create-aio-plugin): add replay and publish parity" +``` + +## Task 4: Productize Example Plugins And The Plugins GUI + +**Files:** +- Create or modify example plugin files under `docs/plugins/examples/` +- Modify: `docs/plugins/examples/README.md` +- Modify: `docs/plugins/examples/privacy-filter.md` +- Modify: `src/pages/PluginsPage.tsx` +- Create: `src/pages/plugins/PluginMarketPanel.tsx` +- Modify: `src/pages/plugins/PluginLifecyclePanel.tsx` +- Modify: `src/pages/plugins/PluginInstallPreviewDialog.tsx` +- Modify: `src/pages/plugins/PluginUpdatePreviewDialog.tsx` +- Modify: `src/pages/__tests__/PluginsPage.test.tsx` +- Modify: `src/pages/plugins/__tests__/pluginProductCopy.test.ts` + +- [ ] **Step 1: Write failing UI tests for runtime reports, market, and example coverage** + +Add or extend tests in `src/pages/__tests__/PluginsPage.test.tsx` to assert: + +```tsx +it("renders runtime reports and replay export actions", async () => { + // Render a plugin with structured runtime reports and expect status, duration, + // trace id, and export actions in the observability section. +}); + +it("renders market state and disables revoked or incompatible installs", async () => { + // Render market listings with revoked and incompatible states and assert + // the install button is disabled and the reason is visible. +}); + +it("keeps privacy filter, prompt helper, and redactor example guidance visible", async () => { + // Ensure the plugin page or example docs references the official examples. +}); +``` + +- [ ] **Step 2: Run the UI tests and verify they fail** + +Run: + +```bash +pnpm test:unit src/pages/__tests__/PluginsPage.test.tsx src/pages/plugins/__tests__/pluginProductCopy.test.ts +``` + +Expected: FAIL because runtime reports, market panel, and example guidance are not yet rendered. + +- [ ] **Step 3: Add the runtime report and market panels** + +In `src/pages/plugins/PluginRuntimeReportsPanel.tsx`, render the new execution report rows and add a replay export button per row. + +In `src/pages/plugins/PluginMarketPanel.tsx`, render: + +```tsx +// market source url, listing status, trust summary, install/update buttons, +// revoked/incompatible blocks, and trusted public key info +``` + +Wire both panels into `src/pages/PluginsPage.tsx` alongside the existing lifecycle panel. + +- [ ] **Step 4: Keep lifecycle, preview, and update dialogs consistent** + +Update `PluginLifecyclePanel.tsx`, `PluginInstallPreviewDialog.tsx`, and `PluginUpdatePreviewDialog.tsx` so trust, compatibility, and rollback phrasing stays consistent with the market and replay flows. + +Avoid adding new copy that implies the GUI can bypass host checks. + +- [ ] **Step 5: Update the example docs** + +Expand `docs/plugins/examples/README.md` so it lists: + +- `official.privacy-filter` +- `examples/prompt-helper` +- `examples/redactor` +- `examples/response-guard` + +Make each example explain which hooks, permissions, and fixtures it covers. + +- [ ] **Step 6: Run the UI tests and doc checks** + +Run: + +```bash +pnpm test:unit src/pages/__tests__/PluginsPage.test.tsx src/pages/plugins/__tests__/pluginProductCopy.test.ts +pnpm check:spec-links +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/pages/PluginsPage.tsx src/pages/plugins/PluginRuntimeReportsPanel.tsx src/pages/plugins/PluginMarketPanel.tsx src/pages/plugins/PluginLifecyclePanel.tsx src/pages/plugins/PluginInstallPreviewDialog.tsx src/pages/plugins/PluginUpdatePreviewDialog.tsx src/pages/__tests__/PluginsPage.test.tsx src/pages/plugins/__tests__/pluginProductCopy.test.ts docs/plugins/examples/README.md docs/plugins/examples/privacy-filter.md +git commit -m "feat(plugins): productize observability and market UI" +``` + +## Task 5: Sync Publishing And Runtime Docs + +**Files:** +- Modify: `docs/plugins/developer-guide.md` +- Modify: `docs/plugins/reference/hooks.md` +- Modify: `docs/plugins/reference/publishing.md` +- Modify: `docs/plugins/reference/README.md` +- Modify: `docs/plugins/runtime/README.md` + +- [ ] **Step 1: Write failing docs checks** + +Extend the existing docs checks or add a focused test script entry so the new replay export and publish-check documentation must exist before release. The docs should mention: + +```text +trace export -> replay -> fix -> pack +plugin_hook_execution_reports +plugin_export_replay_fixture +publish-check +market index URL +trusted public key +revoked / incompatible install blocks +``` + +- [ ] **Step 2: Run the docs checks and verify they fail** + +Run: + +```bash +pnpm check:plugin-system-docs +pnpm check:spec-links +``` + +Expected: fail until the docs are updated. + +- [ ] **Step 3: Update developer and publishing docs** + +Add the new workflow to `docs/plugins/developer-guide.md`: + +```text +doctor -> validate --strict -> replay --explain -> export replay fixture -> fix -> pack -> publish-check -> install/update +``` + +Update `docs/plugins/reference/publishing.md` to explain: + +- publish-check output +- market source URL and trusted public key +- signature and checksum handling +- replay fixtures as a developer workflow artifact +- revoked/incompatible state at install time + +Update `docs/plugins/reference/hooks.md` to describe hook-level observability and replay support per hook. + +Update `docs/plugins/runtime/README.md` to keep the runtime lifecycle boundary explicit and host-owned. + +Update `docs/plugins/reference/README.md` so the new docs are easy to find. + +- [ ] **Step 4: Run the docs checks again** + +Run: + +```bash +pnpm check:plugin-system-docs +pnpm check:spec-links +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add docs/plugins/developer-guide.md docs/plugins/reference/hooks.md docs/plugins/reference/publishing.md docs/plugins/reference/README.md docs/plugins/runtime/README.md +git commit -m "docs(plugins): sync observability and publishing workflow" +``` + +## Verification Strategy + +- Run targeted Rust tests for the runtime report repository and replay exporter before expanding to the full `cargo test --lib`. +- Run `pnpm --filter create-aio-plugin test -- src/scaffold.test.ts` and `pnpm --filter create-aio-plugin typecheck` before touching the GUI. +- Run focused UI tests for `PluginsPage` and plugin product copy before broader `pnpm test:unit`. +- Run `pnpm check:plugin-system-docs`, `pnpm check:spec-links`, and `pnpm check:generated-bindings` before claiming the docs and IPC contracts are stable. +- Use small commits after each task so review can isolate backend, tooling, UI, and docs concerns. + +## Acceptance Criteria + +- The plugin detail surface shows structured runtime evidence instead of only raw audit text. +- A trace can be exported into a replay fixture and replayed by `create-aio-plugin`. +- The host and `create-aio-plugin` remain aligned on core declarative-rule replay behavior. +- Official example plugins cover privacy filter, prompt helper, redactor, and response guard workflows. +- The market UI can load listings, explain revoked/incompatible states, and surface trust information. +- `publish-check` produces release metadata without changing Plugin API v1. +- The docs explain the full developer loop and publishing flow without implying any new high-risk plugin API. From 3a718fbe0676b19674986307003c55a499012f77 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Thu, 25 Jun 2026 19:22:03 +0800 Subject: [PATCH 079/244] feat(plugins): add structured runtime reports --- src-tauri/src/commands/plugins.rs | 33 +- src-tauri/src/commands/registry.rs | 1 + src-tauri/src/domain/plugins.rs | 22 + src-tauri/src/gateway/plugins/audit.rs | 43 +- src-tauri/src/gateway/plugins/permissions.rs | 22 +- src-tauri/src/gateway/plugins/pipeline.rs | 818 +++++++++++++++++- src-tauri/src/gateway/proxy/errors.rs | 3 +- .../failover_loop/attempt/attempt_executor.rs | 3 +- .../response/success_non_stream.rs | 3 +- .../proxy/handler/middleware/body_reader.rs | 3 +- src-tauri/src/gateway/proxy/logging.rs | 3 +- src-tauri/src/gateway/streams/plugin_chunk.rs | 3 +- src-tauri/src/infra/db/migrations/ensure.rs | 28 + src-tauri/src/infra/db/migrations/mod.rs | 6 +- src-tauri/src/infra/db/migrations/tests.rs | 11 +- .../src/infra/db/migrations/v33_to_v34.rs | 62 ++ src-tauri/src/infra/plugins/mod.rs | 1 + .../src/infra/plugins/runtime_reports.rs | 290 +++++++ src/generated/bindings.ts | 36 + 19 files changed, 1330 insertions(+), 61 deletions(-) create mode 100644 src-tauri/src/infra/db/migrations/v33_to_v34.rs create mode 100644 src-tauri/src/infra/plugins/runtime_reports.rs diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs index 8432012c..e0f4873d 100644 --- a/src-tauri/src/commands/plugins.rs +++ b/src-tauri/src/commands/plugins.rs @@ -3,7 +3,8 @@ use crate::app::plugin_service; use crate::app_state::{ensure_db_ready, DbInitState}; use crate::domain::plugins::{ - PluginAuditLog, PluginDetail, PluginInstallPreview, PluginInstallSource, PluginUpdateDiff, + PluginAuditLog, PluginDetail, PluginHookExecutionReport, PluginInstallPreview, + PluginInstallSource, PluginUpdateDiff, }; use crate::infra::plugins::market::PluginMarketListing; use crate::{blocking, plugins}; @@ -63,6 +64,15 @@ 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 PluginMarketIndexInput { @@ -563,6 +573,27 @@ 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) +} + const MAX_REMOTE_PLUGIN_PACKAGE_BYTES: usize = 32 * 1024 * 1024; fn remote_install_source( diff --git a/src-tauri/src/commands/registry.rs b/src-tauri/src/commands/registry.rs index 5af21d5c..50e10e9d 100644 --- a/src-tauri/src/commands/registry.rs +++ b/src-tauri/src/commands/registry.rs @@ -182,6 +182,7 @@ 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, // ── request_logs ── request_logs_list => crate::commands::request_logs::request_logs_list, request_logs_list_all => crate::commands::request_logs::request_logs_list_all, diff --git a/src-tauri/src/domain/plugins.rs b/src-tauri/src/domain/plugins.rs index b87cc475..984a4efe 100644 --- a/src-tauri/src/domain/plugins.rs +++ b/src-tauri/src/domain/plugins.rs @@ -230,6 +230,28 @@ 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, Eq)] #[serde(rename_all = "camelCase")] pub struct PluginLifecycleNotice { diff --git a/src-tauri/src/gateway/plugins/audit.rs b/src-tauri/src/gateway/plugins/audit.rs index aedd01e6..4825e898 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,15 @@ pub(crate) fn persist_gateway_plugin_error_audit_events( err: &mut GatewayPluginError, ) { let events = err.take_audit_events(); - if events.is_empty() { - return; - } - persist_gateway_plugin_audit_events(db, trace_id, events); + let reports = err.take_execution_reports(); + persist_gateway_plugin_diagnostics(db, trace_id, events, reports); } -pub(crate) fn persist_gateway_plugin_audit_events( +pub(crate) fn persist_gateway_plugin_diagnostics( 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 +69,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/permissions.rs b/src-tauri/src/gateway/plugins/permissions.rs index 4920df26..d0fe1e0b 100644 --- a/src-tauri/src/gateway/plugins/permissions.rs +++ b/src-tauri/src/gateway/plugins/permissions.rs @@ -2,7 +2,7 @@ use super::context::{GatewayHookResult, GatewayPluginHookName}; use super::mutation; -use super::pipeline::GatewayPluginAuditEvent; +use super::pipeline::{GatewayPluginAuditEvent, GatewayPluginHookExecutionReport}; use super::registry::HookRegistry; use std::fmt; @@ -11,6 +11,7 @@ pub(crate) struct GatewayPluginError { code: &'static str, message: String, audit_events: Vec, + execution_reports: Vec, } impl GatewayPluginError { @@ -19,6 +20,7 @@ impl GatewayPluginError { code, message: message.into(), audit_events: Vec::new(), + execution_reports: Vec::new(), } } @@ -40,14 +42,32 @@ 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 { diff --git a/src-tauri/src/gateway/plugins/pipeline.rs b/src-tauri/src/gateway/plugins/pipeline.rs index 31d223d6..14ca7b48 100644 --- a/src-tauri/src/gateway/plugins/pipeline.rs +++ b/src-tauri/src/gateway/plugins/pipeline.rs @@ -11,6 +11,7 @@ use super::permissions::{ }; use super::registry::HookRegistry; use crate::domain::plugins::{PluginDetail, PluginStatus}; +use crate::shared::time::now_unix_millis; use axum::body::Bytes; use axum::http::{HeaderMap, HeaderName, HeaderValue}; use std::collections::HashMap; @@ -125,6 +126,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, @@ -137,6 +158,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)] @@ -145,6 +167,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)] @@ -152,12 +175,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)] @@ -225,6 +250,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() { @@ -237,6 +263,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; } @@ -250,6 +291,8 @@ impl GatewayPluginPipeline { self.config.context_budget, ); let truncation = VisibleTruncationState::from_context(&visible); + let started_at_ms = now_unix_millis(); + let started = Instant::now(); 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, @@ -263,8 +306,33 @@ impl GatewayPluginPipeline { "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 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("hook_error"), + error_code: Some(err.code_for_logging()), + 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; } @@ -284,12 +352,37 @@ impl GatewayPluginPipeline { "Plugin hook timed out", serde_json::json!({ "failureKind": "timeout" }), )); - if failure_policy(plugin, input.hook_name) == FailurePolicy::FailClosed { - return Err(GatewayPluginError::new( + 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("timeout"), + error_code: Some("PLUGIN_HOOK_TIMEOUT"), + mutation_summary: serde_json::json!({ "changed": false }), + replayable: true, + replay_export_reason: None, + }, + )); + if fail_closed { + let err = GatewayPluginError::new( "PLUGIN_HOOK_TIMEOUT", format!("plugin hook timed out: {}", plugin.summary.plugin_id), - ) - .with_audit_events(audit_events)); + ); + return Err(attach_plugin_diagnostics( + err, + audit_events, + execution_reports, + )); } continue; } @@ -311,19 +404,61 @@ 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) { + 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()); @@ -335,6 +470,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, @@ -343,8 +493,24 @@ impl GatewayPluginPipeline { reason, }), audit_events, + execution_reports, }); } + 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, @@ -360,6 +526,7 @@ impl GatewayPluginPipeline { body, blocked: None, audit_events, + execution_reports, }) } @@ -370,6 +537,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() { @@ -382,6 +550,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; } @@ -395,6 +578,8 @@ impl GatewayPluginPipeline { self.config.context_budget, ); let truncation = VisibleTruncationState::from_context(&visible); + let started_at_ms = now_unix_millis(); + let started = Instant::now(); let result = match tokio::time::timeout( self.config.hook_timeout, self.executor.execute_response_hook(plugin, visible), @@ -405,17 +590,66 @@ impl GatewayPluginPipeline { Ok(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 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("hook_error"), + error_code: Some(err.code_for_logging()), + 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; } 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("timeout"), + error_code: Some("PLUGIN_HOOK_TIMEOUT"), + mutation_summary: serde_json::json!({ "changed": false }), + replayable: true, + replay_export_reason: None, + }, + )); + if fail_closed { + return Err(attach_plugin_diagnostics( + timeout_error(&plugin.summary.plugin_id), + audit_events, + execution_reports, + )); } continue; } @@ -430,19 +664,61 @@ impl GatewayPluginPipeline { ) { 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) { + 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()); @@ -454,6 +730,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, @@ -462,8 +753,24 @@ impl GatewayPluginPipeline { reason, }), audit_events, + execution_reports, }); } + 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)); } @@ -472,6 +779,7 @@ impl GatewayPluginPipeline { body, blocked: None, audit_events, + execution_reports, }) } @@ -482,6 +790,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() { @@ -494,6 +803,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; } @@ -506,6 +830,8 @@ impl GatewayPluginPipeline { self.config.context_budget, ); let truncation = VisibleTruncationState::from_context(&visible); + let started_at_ms = now_unix_millis(); + let started = Instant::now(); let result = match tokio::time::timeout( self.config.hook_timeout, self.executor.execute_stream_hook(plugin, visible), @@ -516,17 +842,66 @@ impl GatewayPluginPipeline { Ok(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 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("hook_error"), + error_code: Some(err.code_for_logging()), + 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; } 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("timeout"), + error_code: Some("PLUGIN_HOOK_TIMEOUT"), + mutation_summary: serde_json::json!({ "changed": false }), + replayable: true, + replay_export_reason: None, + }, + )); + if fail_closed { + return Err(attach_plugin_diagnostics( + timeout_error(&plugin.summary.plugin_id), + audit_events, + execution_reports, + )); } continue; } @@ -541,17 +916,38 @@ impl GatewayPluginPipeline { ) { 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()); @@ -563,6 +959,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 { @@ -570,14 +981,31 @@ impl GatewayPluginPipeline { reason, }), audit_events, + execution_reports, }); } + 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, }) } @@ -588,6 +1016,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() { @@ -600,6 +1029,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; } @@ -612,6 +1056,8 @@ impl GatewayPluginPipeline { self.config.context_budget, ); let truncation = VisibleTruncationState::from_context(&visible); + let started_at_ms = now_unix_millis(); + let started = Instant::now(); let result = match tokio::time::timeout( self.config.hook_timeout, self.executor.execute_log_hook(plugin, visible), @@ -622,17 +1068,66 @@ impl GatewayPluginPipeline { Ok(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 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("hook_error"), + error_code: Some(err.code_for_logging()), + 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; } 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("timeout"), + error_code: Some("PLUGIN_HOOK_TIMEOUT"), + mutation_summary: serde_json::json!({ "changed": false }), + replayable: true, + replay_export_reason: None, + }, + )); + if fail_closed { + return Err(attach_plugin_diagnostics( + timeout_error(&plugin.summary.plugin_id), + audit_events, + execution_reports, + )); } continue; } @@ -647,22 +1142,58 @@ impl GatewayPluginPipeline { ) { 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(); } + 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, }) } @@ -777,6 +1308,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)] @@ -785,6 +1366,15 @@ 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], @@ -810,6 +1400,15 @@ fn enforce_hook_result_with_budget( 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, @@ -879,6 +1478,94 @@ fn plugin_hook( .find(|hook| hook.name == hook_name.as_str()) } +fn runtime_kind(plugin: &PluginDetail) -> String { + match &plugin.manifest.runtime { + crate::domain::plugins::PluginRuntime::DeclarativeRules { .. } => { + "declarativeRules".to_string() + } + crate::domain::plugins::PluginRuntime::Native { engine } => format!("native:{engine}"), + crate::domain::plugins::PluginRuntime::Wasm { .. } => "wasm".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 { let mut by_hook: HashMap> = HashMap::new(); for plugin in plugins.iter() { @@ -2051,6 +2738,55 @@ 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"]); + plugin.manifest.hooks[0].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")] diff --git a/src-tauri/src/gateway/proxy/errors.rs b/src-tauri/src/gateway/proxy/errors.rs index 8db7c566..79c27684 100644 --- a/src-tauri/src/gateway/proxy/errors.rs +++ b/src-tauri/src/gateway/proxy/errors.rs @@ -175,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 } 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..30367af9 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!( 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..ebbf3616 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 @@ -1042,10 +1042,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!( 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/logging.rs b/src-tauri/src/gateway/proxy/logging.rs index 9f7d7b3b..17caba6e 100644 --- a/src-tauri/src/gateway/proxy/logging.rs +++ b/src-tauri/src/gateway/proxy/logging.rs @@ -267,10 +267,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!( diff --git a/src-tauri/src/gateway/streams/plugin_chunk.rs b/src-tauri/src/gateway/streams/plugin_chunk.rs index d60049f3..adee2f2a 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!( diff --git a/src-tauri/src/infra/db/migrations/ensure.rs b/src-tauri/src/infra/db/migrations/ensure.rs index 97c0462a..df11fd5c 100644 --- a/src-tauri/src/infra/db/migrations/ensure.rs +++ b/src-tauri/src/infra/db/migrations/ensure.rs @@ -1105,6 +1105,34 @@ 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_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}"))?; diff --git a/src-tauri/src/infra/db/migrations/mod.rs b/src-tauri/src/infra/db/migrations/mod.rs index 57f77708..ffcf2034 100644 --- a/src-tauri/src/infra/db/migrations/mod.rs +++ b/src-tauri/src/infra/db/migrations/mod.rs @@ -10,11 +10,12 @@ mod v29_to_v30; mod v30_to_v31; mod v31_to_v32; mod v32_to_v33; +mod v33_to_v34; use rusqlite::Connection; -const LATEST_SCHEMA_VERSION: i64 = 33; -const MAX_COMPAT_SCHEMA_VERSION: i64 = 34; +const LATEST_SCHEMA_VERSION: i64 = 34; +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 +56,7 @@ 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)?, 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..11d00b40 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), @@ -840,7 +841,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, 34); for column in [ "auth_mode", @@ -1096,7 +1097,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, 34); assert!(test_has_column(&conn, "workspaces", "cli_key")); assert!(test_has_column(&conn, "workspace_active", "workspace_id")); @@ -1217,7 +1218,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, 34); // Verify all tables exist let tables: Vec = { @@ -1242,6 +1243,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 +1454,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, 34); assert!(test_has_column(&conn, "providers", "limit_5h_usd")); assert!(test_has_column(&conn, "providers", "limit_daily_usd")); @@ -1462,6 +1464,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/plugins/mod.rs b/src-tauri/src/infra/plugins/mod.rs index a59ee7c6..c0cddf99 100644 --- a/src-tauri/src/infra/plugins/mod.rs +++ b/src-tauri/src/infra/plugins/mod.rs @@ -3,4 +3,5 @@ pub(crate) mod market; pub(crate) mod package; pub(crate) mod repository; +pub(crate) mod runtime_reports; pub(crate) mod signing; 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..c5c803e9 --- /dev/null +++ b/src-tauri/src/infra/plugins/runtime_reports.rs @@ -0,0 +1,290 @@ +//! Usage: Structured plugin hook execution report persistence. + +use crate::db; +use crate::domain::plugins::PluginHookExecutionReport; +use crate::shared::error::{db_err, AppResult}; +use crate::shared::time::now_unix_seconds; +use rusqlite::{params, params_from_iter, types::Value}; + +#[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, +} + +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) +} + +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(); + get_hook_execution_report_by_id(conn, id) +} + +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) +} + +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 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 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::*; + + #[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: "declarativeRules".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"); + } +} diff --git a/src/generated/bindings.ts b/src/generated/bindings.ts index a0c8c3d2..1fb7b08d 100644 --- a/src/generated/bindings.ts +++ b/src/generated/bindings.ts @@ -1577,6 +1577,16 @@ export const commands = { else return { status: "error", error: e as any }; } }, + async pluginListRuntimeReports( + input: PluginListRuntimeReportsInput + ): Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin_list_runtime_reports", { input }) }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, async requestLogsList( cliKey: string, limit: number | null @@ -2731,6 +2741,26 @@ export type PluginDetail = { export type PluginGetInput = { pluginId: string }; export type PluginGrantPermissionsInput = { pluginId: string; permissions: string[] }; export type PluginHook = { name: string; priority?: number; failurePolicy?: string | null }; +export type PluginHookExecutionReport = { + id: number; + plugin_id: string; + trace_id: string | null; + hook_name: string; + runtime_kind: string; + status: string; + started_at_ms: number; + duration_ms: number; + failure_kind: string | null; + error_code: string | null; + failure_policy: string | null; + circuit_state: string | null; + context_budget: JsonValue; + output_budget: JsonValue; + mutation_summary: JsonValue; + replayable: boolean; + replay_export_reason: string | null; + created_at: number; +}; export type PluginHookLifecycleSummary = { name: string; priority: number; @@ -2776,6 +2806,12 @@ export type PluginLifecycleChange = { }; export type PluginLifecycleNotice = { severity: string; code: string; message: string }; export type PluginListAuditLogsInput = { pluginId: string | null; limit: number | null }; +export type PluginListRuntimeReportsInput = { + pluginId: string | null; + hookName: string | null; + traceId: string | null; + limit: number | null; +}; export type PluginManifest = { id: string; name: string; From 403833904a4b4b4022d763e37a44b3b16bcc2f43 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Thu, 25 Jun 2026 20:01:34 +0800 Subject: [PATCH 080/244] feat(plugins): export trace replay fixtures --- src-tauri/src/commands/plugins.rs | 32 ++- src-tauri/src/commands/registry.rs | 1 + src-tauri/src/domain/plugins.rs | 78 ++++++ src-tauri/src/infra/plugins/mod.rs | 1 + src-tauri/src/infra/plugins/replay_export.rs | 263 +++++++++++++++++++ src/generated/bindings.ts | 72 +++++ src/pages/PluginsPage.tsx | 90 ++++++- src/pages/__tests__/PluginsPage.test.tsx | 167 ++++++++++++ src/query/__tests__/plugins.test.tsx | 121 +++++++++ src/query/keys.ts | 8 + src/query/plugins.ts | 46 ++++ src/services/__tests__/plugins.test.ts | 69 +++++ src/services/gateway/requestLogs.ts | 14 + src/services/plugins.ts | 48 ++++ 14 files changed, 1008 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/infra/plugins/replay_export.rs diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs index e0f4873d..11ae70b8 100644 --- a/src-tauri/src/commands/plugins.rs +++ b/src-tauri/src/commands/plugins.rs @@ -4,7 +4,7 @@ use crate::app::plugin_service; use crate::app_state::{ensure_db_ready, DbInitState}; use crate::domain::plugins::{ PluginAuditLog, PluginDetail, PluginHookExecutionReport, PluginInstallPreview, - PluginInstallSource, PluginUpdateDiff, + PluginInstallSource, PluginReplayFixture, PluginUpdateDiff, }; use crate::infra::plugins::market::PluginMarketListing; use crate::{blocking, plugins}; @@ -73,6 +73,14 @@ pub(crate) struct PluginListRuntimeReportsInput { 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 PluginMarketIndexInput { @@ -594,6 +602,28 @@ pub(crate) async fn plugin_list_runtime_reports( .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( diff --git a/src-tauri/src/commands/registry.rs b/src-tauri/src/commands/registry.rs index 50e10e9d..d9dd7d06 100644 --- a/src-tauri/src/commands/registry.rs +++ b/src-tauri/src/commands/registry.rs @@ -183,6 +183,7 @@ macro_rules! generated_command_registry { 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_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, diff --git a/src-tauri/src/domain/plugins.rs b/src-tauri/src/domain/plugins.rs index 984a4efe..879590c9 100644 --- a/src-tauri/src/domain/plugins.rs +++ b/src-tauri/src/domain/plugins.rs @@ -252,6 +252,84 @@ pub struct PluginHookExecutionReport { 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 { diff --git a/src-tauri/src/infra/plugins/mod.rs b/src-tauri/src/infra/plugins/mod.rs index c0cddf99..aa08c4c8 100644 --- a/src-tauri/src/infra/plugins/mod.rs +++ b/src-tauri/src/infra/plugins/mod.rs @@ -2,6 +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/replay_export.rs b/src-tauri/src/infra/plugins/replay_export.rs new file mode 100644 index 00000000..d945cdb7 --- /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: "declarativeRules".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/generated/bindings.ts b/src/generated/bindings.ts index 1fb7b08d..c102bc8f 100644 --- a/src/generated/bindings.ts +++ b/src/generated/bindings.ts @@ -1587,6 +1587,16 @@ export const commands = { else return { status: "error", error: e as any }; } }, + async pluginExportReplayFixture( + input: PluginExportReplayFixtureInput + ): Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin_export_replay_fixture", { input }) }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, async requestLogsList( cliKey: string, limit: number | null @@ -2738,6 +2748,11 @@ export type PluginDetail = { runtime_failures: PluginRuntimeFailure[]; rollback_versions: string[]; }; +export type PluginExportReplayFixtureInput = { + traceId: string; + hookName: string; + pluginId: string | null; +}; export type PluginGetInput = { pluginId: string }; export type PluginGrantPermissionsInput = { pluginId: string; permissions: string[] }; export type PluginHook = { name: string; priority?: number; failurePolicy?: string | null }; @@ -2865,6 +2880,63 @@ export type PluginPermissionLifecycleSummary = { export type PluginPermissionRisk = "low" | "medium" | "high" | "critical"; export type PluginPreviewFromFileInput = { filePath: string }; export type PluginPreviewUpdateFromFileInput = { filePath: string }; +export type PluginReplayFixture = { + schemaVersion: number; + traceId: string; + source: PluginReplayFixtureSource; + hookName: string; + pluginId: string | null; + request: PluginReplayFixtureRequest; + response: PluginReplayFixtureResponse; + log: PluginReplayFixtureLog; + attempts: PluginReplayFixtureAttempt[]; + runtimeReports: PluginHookExecutionReport[]; + notes: string[]; +}; +export type PluginReplayFixtureAttempt = { + id: number; + traceId: string; + cliKey: string; + attemptIndex: number; + providerId: number; + providerName: string; + baseUrl: string; + outcome: string; + status: number | null; + attemptStartedMs: number; + attemptDurationMs: number; + createdAt: number; +}; +export type PluginReplayFixtureLog = { body: JsonValue | null; meta: JsonValue }; +export type PluginReplayFixtureRequest = { + cliKey: string; + sessionId: string | null; + method: string | null; + path: string | null; + query: string | null; + provider: string | null; + providerSource: string | null; + model: string | null; + headers: JsonValue | null; + body: JsonValue | null; + normalizedMessages: JsonValue[]; + meta: JsonValue; +}; +export type PluginReplayFixtureResponse = { + status: number | null; + errorCode: string | null; + headers: JsonValue | null; + body: JsonValue | null; + chunks: JsonValue[]; + meta: JsonValue; +}; +export type PluginReplayFixtureSource = { + appVersion: string; + traceId: string; + exportedAtMs: number; + requestLogId: number; + createdAtMs: number; +}; export type PluginRevokePermissionInput = { pluginId: string; permission: string }; export type PluginRollbackInput = { pluginId: string; version: string }; export type PluginRuntime = diff --git a/src/pages/PluginsPage.tsx b/src/pages/PluginsPage.tsx index f21e8406..280f824f 100644 --- a/src/pages/PluginsPage.tsx +++ b/src/pages/PluginsPage.tsx @@ -17,6 +17,7 @@ import { openDesktopSinglePath } from "../services/desktop/dialog"; import type { JsonValue, PluginDetail, + PluginHookExecutionReport, PluginInstallPreview, PluginPermissionRisk, PluginStatus, @@ -35,9 +36,11 @@ import { usePluginInstallOfficialMutation, usePluginPreviewFromFileMutation, usePluginPreviewUpdateFromFileMutation, + usePluginExportReplayFixtureMutation, usePluginQuery, usePluginRollbackMutation, usePluginSaveConfigMutation, + usePluginRuntimeReportsQuery, usePluginUninstallMutation, usePluginUpdateFromFileMutation, usePluginsListQuery, @@ -167,10 +170,39 @@ function TraceIdButton({ traceId }: { traceId: string | null | undefined }) { ); } +function reportFailureLabel(report: PluginHookExecutionReport) { + return report.failure_kind ?? report.error_code ?? "-"; +} + function RuntimeObservabilitySection({ detail }: { detail: PluginDetail }) { + const reportsQuery = usePluginRuntimeReportsQuery({ + pluginId: detail.summary.plugin_id, + limit: 8, + }); + const replayExportMutation = usePluginExportReplayFixtureMutation(); + const reports = reportsQuery.data?.slice(0, 8) ?? []; const failures = detail.runtime_failures.slice(0, 5); const auditLogs = detail.audit_logs.slice(0, 8); - const empty = failures.length === 0 && auditLogs.length === 0; + const empty = + reports.length === 0 && + failures.length === 0 && + auditLogs.length === 0 && + !reportsQuery.isLoading; + + async function handleExportReplayFixture(report: PluginHookExecutionReport) { + if (!report.trace_id) return; + try { + const fixture = await replayExportMutation.mutateAsync({ + traceId: report.trace_id, + hookName: report.hook_name, + pluginId: report.plugin_id, + }); + await copyText(JSON.stringify(fixture, null, 2)); + toast.success("Replay fixture 已复制"); + } catch (error) { + toast.error(formatActionFailureToast("导出 replay fixture", error).toast); + } + } return (
@@ -180,6 +212,62 @@ function RuntimeObservabilitySection({ detail }: { detail: PluginDetail }) {
) : (
+ {reportsQuery.isLoading ? ( +
+ 正在读取插件运行报告 +
+ ) : null} + + {reports.map((report) => ( +
+
+ {report.status} + + {report.runtime_kind} + +
+
+
+
Hook
+
{report.hook_name}
+
+
+
耗时
+
+ {report.duration_ms}ms +
+
+
+
Failure
+
+ {reportFailureLabel(report)} +
+
+
+
Trace ID
+ +
+
+
+ + {report.replay_export_reason ? ( + + {report.replay_export_reason} + + ) : null} +
+
+ ))} + {failures.map((failure) => (
{ usePluginInstallOfficialMutation: vi.fn(), usePluginPreviewFromFileMutation: vi.fn(), usePluginPreviewUpdateFromFileMutation: vi.fn(), + usePluginExportReplayFixtureMutation: vi.fn(), usePluginUpdateFromFileMutation: vi.fn(), usePluginRollbackMutation: vi.fn(), usePluginEnableMutation: vi.fn(), @@ -64,6 +69,7 @@ vi.mock("../../query/plugins", async () => { usePluginDisableMutation: vi.fn(), usePluginUninstallMutation: vi.fn(), usePluginSaveConfigMutation: vi.fn(), + usePluginRuntimeReportsQuery: vi.fn(), }; }); @@ -222,6 +228,75 @@ function updateDiff(overrides: Partial = {}): PluginUpdateDiff }; } +function runtimeReport( + overrides: Partial = {} +): PluginHookExecutionReport { + return { + id: 1, + plugin_id: "community.prompt-helper", + trace_id: "trace-report-1", + hook_name: "gateway.request.afterBodyRead", + runtime_kind: "declarativeRules", + status: "completed", + started_at_ms: 1000, + duration_ms: 9, + failure_kind: null, + error_code: null, + failure_policy: "fail-open", + circuit_state: "closed", + context_budget: {}, + output_budget: {}, + mutation_summary: { changed: true, field: "requestBody" }, + replayable: true, + replay_export_reason: null, + created_at: 10, + ...overrides, + }; +} + +function replayFixture(overrides: Partial = {}): PluginReplayFixture { + return { + schemaVersion: 1, + traceId: "trace-report-1", + source: { + appVersion: "0.62.3", + traceId: "trace-report-1", + exportedAtMs: 1000, + requestLogId: 1, + createdAtMs: 900, + }, + hookName: "gateway.request.afterBodyRead", + pluginId: "community.prompt-helper", + request: { + cliKey: "codex", + sessionId: null, + method: "POST", + path: "/v1/responses", + query: null, + provider: "OpenAI Primary", + providerSource: null, + model: "gpt-5-mini", + headers: null, + body: null, + normalizedMessages: [], + meta: {}, + }, + response: { + status: 200, + errorCode: null, + headers: null, + body: null, + chunks: [], + meta: {}, + }, + log: { body: null, meta: {} }, + attempts: [], + runtimeReports: [], + notes: ["request body is not persisted"], + ...overrides, + }; +} + function mutation(overrides: Record = {}) { return { mutateAsync: vi.fn().mockResolvedValue(detail()), @@ -257,6 +332,15 @@ describe("pages/PluginsPage", () => { vi.mocked(usePluginDisableMutation).mockReturnValue(mutation() as any); vi.mocked(usePluginUninstallMutation).mockReturnValue(mutation() as any); vi.mocked(usePluginSaveConfigMutation).mockReturnValue(mutation() as any); + vi.mocked(usePluginExportReplayFixtureMutation).mockReturnValue( + mutation({ mutateAsync: vi.fn().mockResolvedValue(replayFixture()) }) as any + ); + vi.mocked(usePluginRuntimeReportsQuery).mockReturnValue({ + data: [], + isLoading: false, + isFetching: false, + error: null, + } as any); vi.mocked(usePluginQuery).mockReturnValue({ data: detail(), isLoading: false, @@ -339,6 +423,45 @@ describe("pages/PluginsPage", () => { expect(screen.queryByText("签名已验证")).not.toBeInTheDocument(); }); + it("renders runtime reports and exports replay fixtures", async () => { + const { copyText } = await import("../../services/clipboard"); + const exportReplay = vi.fn().mockResolvedValue(replayFixture()); + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary()], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginRuntimeReportsQuery).mockReturnValue({ + data: [runtimeReport()], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginExportReplayFixtureMutation).mockReturnValue( + mutation({ mutateAsync: exportReplay }) as any + ); + + renderWithProviders(); + + expect(screen.getByText("completed")).toBeInTheDocument(); + expect(screen.getByText("declarativeRules")).toBeInTheDocument(); + expect(screen.getByText("9ms")).toBeInTheDocument(); + expect(screen.getByText("trace-report-1")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /导出 Replay/ })); + + await waitFor(() => { + expect(exportReplay).toHaveBeenCalledWith({ + traceId: "trace-report-1", + hookName: "gateway.request.afterBodyRead", + pluginId: "community.prompt-helper", + }); + expect(copyText).toHaveBeenCalledWith(expect.stringContaining('"traceId": "trace-report-1"')); + expect(toast.success).toHaveBeenCalledWith("Replay fixture 已复制"); + }); + }); + it("uses only the latest lifecycle audit for trust state", () => { vi.mocked(usePluginsListQuery).mockReturnValue({ data: [summary()], @@ -428,6 +551,50 @@ describe("pages/PluginsPage", () => { }); }); + it("renders structured runtime reports and copies replay fixtures", async () => { + const { copyText } = await import("../../services/clipboard"); + const mutateAsync = vi.fn().mockResolvedValue(replayFixture()); + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary()], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginRuntimeReportsQuery).mockReturnValue({ + data: [ + runtimeReport({ + trace_id: "trace-report-1", + duration_ms: 17, + status: "completed", + }), + ], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.mocked(usePluginExportReplayFixtureMutation).mockReturnValue( + mutation({ mutateAsync }) as any + ); + + renderWithProviders(); + + expect(screen.getByText("completed")).toBeInTheDocument(); + expect(screen.getByText("17ms")).toBeInTheDocument(); + expect(screen.getByText("trace-report-1")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /导出 Replay/ })); + + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ + traceId: "trace-report-1", + hookName: "gateway.request.afterBodyRead", + pluginId: "community.prompt-helper", + }); + expect(copyText).toHaveBeenCalledWith(expect.stringContaining('"traceId": "trace-report-1"')); + expect(toast.success).toHaveBeenCalledWith("Replay fixture 已复制"); + }); + }); + it("renders audit logs with risk, event, trace, and detail fields", () => { vi.mocked(usePluginsListQuery).mockReturnValue({ data: [summary()], diff --git a/src/query/__tests__/plugins.test.tsx b/src/query/__tests__/plugins.test.tsx index 8acb3f2d..62962c60 100644 --- a/src/query/__tests__/plugins.test.tsx +++ b/src/query/__tests__/plugins.test.tsx @@ -8,6 +8,8 @@ import { pluginInstallRemote, pluginInstallOfficial, pluginList, + pluginListRuntimeReports, + pluginExportReplayFixture, pluginQuarantineRevoked, pluginRevokePermission, pluginRollback, @@ -24,8 +26,10 @@ import { usePluginInstallRemoteMutation, usePluginQuery, usePluginQuarantineRevokedMutation, + usePluginExportReplayFixtureMutation, usePluginRevokePermissionMutation, usePluginRollbackMutation, + usePluginRuntimeReportsQuery, usePluginsListQuery, usePluginSaveConfigMutation, usePluginUninstallMutation, @@ -42,6 +46,8 @@ vi.mock("../../services/plugins", async () => { pluginEnable: vi.fn(), pluginInstallRemote: vi.fn(), pluginInstallOfficial: vi.fn(), + pluginListRuntimeReports: vi.fn(), + pluginExportReplayFixture: vi.fn(), pluginQuarantineRevoked: vi.fn(), pluginUpdateFromFile: vi.fn(), pluginRollback: vi.fn(), @@ -139,6 +145,121 @@ describe("query/plugins", () => { expect(client.getQueryState(pluginKeys.detail("community.prompt-helper"))).toBeTruthy(); }); + it("queries runtime reports and caches exported replay fixtures", async () => { + vi.mocked(pluginListRuntimeReports).mockResolvedValue([ + { + id: 1, + plugin_id: "community.prompt-helper", + trace_id: "trace-replay-1", + hook_name: "gateway.request.afterBodyRead", + runtime_kind: "declarativeRules", + status: "completed", + started_at_ms: 1000, + duration_ms: 7, + failure_kind: null, + error_code: null, + failure_policy: "fail-open", + circuit_state: "closed", + context_budget: {}, + output_budget: {}, + mutation_summary: { changed: true }, + replayable: true, + replay_export_reason: null, + created_at: 10, + }, + ]); + vi.mocked(pluginExportReplayFixture).mockResolvedValue({ + schemaVersion: 1, + traceId: "trace-replay-1", + source: { + appVersion: "0.62.3", + traceId: "trace-replay-1", + exportedAtMs: 1000, + requestLogId: 1, + createdAtMs: 900, + }, + hookName: "gateway.request.afterBodyRead", + pluginId: "community.prompt-helper", + request: { + cliKey: "codex", + sessionId: null, + method: "POST", + path: "/v1/responses", + query: null, + provider: "OpenAI Primary", + providerSource: null, + model: "gpt-5-mini", + headers: null, + body: null, + normalizedMessages: [], + meta: {}, + }, + response: { + status: 200, + errorCode: null, + headers: null, + body: null, + chunks: [], + meta: {}, + }, + log: { body: null, meta: {} }, + attempts: [], + runtimeReports: [], + notes: [], + }); + const client = createTestQueryClient(); + const wrapper = createQueryWrapper(client); + + renderHook( + () => + usePluginRuntimeReportsQuery({ + pluginId: " community.prompt-helper ", + hookName: "gateway.request.afterBodyRead", + traceId: "trace-replay-1", + limit: 25, + }), + { wrapper } + ); + + await waitFor(() => { + expect(pluginListRuntimeReports).toHaveBeenCalledWith({ + pluginId: "community.prompt-helper", + hookName: "gateway.request.afterBodyRead", + traceId: "trace-replay-1", + limit: 25, + }); + }); + expect( + client.getQueryState( + pluginKeys.runtimeReports( + "community.prompt-helper", + "gateway.request.afterBodyRead", + "trace-replay-1", + 25 + ) + ) + ).toBeTruthy(); + + const { result } = renderHook(() => usePluginExportReplayFixtureMutation(), { wrapper }); + await act(async () => { + await result.current.mutateAsync({ + pluginId: " community.prompt-helper ", + hookName: "gateway.request.afterBodyRead", + traceId: "trace-replay-1", + }); + }); + + expect( + client.getQueryData( + pluginKeys.replayFixture( + "trace-replay-1", + "gateway.request.afterBodyRead", + "community.prompt-helper" + ) + ) + ).toMatchObject({ traceId: "trace-replay-1" }); + }); + it("invalidates list and detail queries after mutations", async () => { const next = detail({ summary: summary({ status: "enabled" }) }); vi.mocked(pluginEnable).mockResolvedValue(next); diff --git a/src/query/keys.ts b/src/query/keys.ts index f875ab55..0e8f3154 100644 --- a/src/query/keys.ts +++ b/src/query/keys.ts @@ -246,6 +246,14 @@ export const pluginKeys = { [...pluginsAllKey, "updatePreview", filePath] as const, auditLogs: (pluginId: string | null, limit: number | null) => [...pluginsAllKey, "auditLogs", pluginId, limit] as const, + runtimeReports: ( + pluginId: string | null, + hookName: string | null, + traceId: string | null, + limit: number | null + ) => [...pluginsAllKey, "runtimeReports", pluginId, hookName, traceId, limit] as const, + replayFixture: (traceId: string | null, hookName: string | null, pluginId: string | null) => + [...pluginsAllKey, "replayFixture", traceId, hookName, pluginId] as const, }; const settingsAllKey = ["settings"] as const; diff --git a/src/query/plugins.ts b/src/query/plugins.ts index d113f6b9..660b4a91 100644 --- a/src/query/plugins.ts +++ b/src/query/plugins.ts @@ -12,8 +12,10 @@ import { pluginInstallOfficial, pluginList, pluginListAuditLogs, + pluginListRuntimeReports, pluginPreviewFromFile, pluginPreviewUpdateFromFile, + pluginExportReplayFixture, pluginQuarantineRevoked, pluginRevokePermission, pluginRollback, @@ -84,6 +86,50 @@ export function usePluginAuditLogsQuery( }); } +export function usePluginRuntimeReportsQuery( + input: { + pluginId: string | null; + hookName?: string | null; + traceId?: string | null; + limit?: number | null; + }, + options?: { enabled?: boolean } +) { + const normalizedPluginId = input.pluginId == null ? null : normalizePluginId(input.pluginId); + const hookName = input.hookName ?? null; + const traceId = input.traceId ?? null; + const limit = input.limit ?? 50; + + return useQuery({ + queryKey: pluginKeys.runtimeReports(normalizedPluginId, hookName, traceId, limit), + queryFn: () => + pluginListRuntimeReports({ + pluginId: normalizedPluginId, + hookName, + traceId, + limit, + }), + enabled: (options?.enabled ?? true) && normalizedPluginId != null, + placeholderData: keepPreviousData, + }); +} + +export function usePluginExportReplayFixtureMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: { traceId: string; hookName: string; pluginId?: string | null }) => + pluginExportReplayFixture(input), + onSuccess: (fixture, input) => { + const pluginId = input.pluginId == null ? null : normalizePluginId(input.pluginId); + queryClient.setQueryData( + pluginKeys.replayFixture(fixture.traceId, fixture.hookName, pluginId), + fixture + ); + }, + }); +} + export function usePluginInstallFromFileMutation() { const queryClient = useQueryClient(); diff --git a/src/services/__tests__/plugins.test.ts b/src/services/__tests__/plugins.test.ts index 384a367e..30ddf660 100644 --- a/src/services/__tests__/plugins.test.ts +++ b/src/services/__tests__/plugins.test.ts @@ -11,7 +11,9 @@ import { pluginInstallOfficial, pluginList, pluginListAuditLogs, + pluginListRuntimeReports, pluginParseMarketIndex, + pluginExportReplayFixture, pluginQuarantineRevoked, pluginRevokePermission, pluginRollback, @@ -38,6 +40,8 @@ vi.mock("../../generated/bindings", () => ({ pluginGrantPermissions: vi.fn(), pluginRevokePermission: vi.fn(), pluginListAuditLogs: vi.fn(), + pluginListRuntimeReports: vi.fn(), + pluginExportReplayFixture: vi.fn(), }, })); @@ -121,6 +125,49 @@ describe("services/plugins", () => { vi.mocked(commands.pluginGrantPermissions).mockResolvedValue({ status: "ok", data: detail }); vi.mocked(commands.pluginRevokePermission).mockResolvedValue({ status: "ok", data: detail }); vi.mocked(commands.pluginListAuditLogs).mockResolvedValue({ status: "ok", data: [] }); + vi.mocked(commands.pluginListRuntimeReports).mockResolvedValue({ status: "ok", data: [] }); + vi.mocked(commands.pluginExportReplayFixture).mockResolvedValue({ + status: "ok", + data: { + schemaVersion: 1, + traceId: "trace-replay-1", + source: { + appVersion: "0.62.3", + traceId: "trace-replay-1", + exportedAtMs: 1000, + requestLogId: 1, + createdAtMs: 900, + }, + hookName: "gateway.request.afterBodyRead", + pluginId: "community.prompt-helper", + request: { + cliKey: "codex", + sessionId: null, + method: "POST", + path: "/v1/responses", + query: null, + provider: "OpenAI Primary", + providerSource: null, + model: "gpt-5-mini", + headers: null, + body: null, + normalizedMessages: [], + meta: {}, + }, + response: { + status: 200, + errorCode: null, + headers: null, + body: null, + chunks: [], + meta: {}, + }, + log: { body: null, meta: {} }, + attempts: [], + runtimeReports: [], + notes: ["request body is not persisted"], + }, + }); await pluginInstallFromFile(" /tmp/plugin.json "); await pluginInstallRemote({ @@ -152,6 +199,17 @@ describe("services/plugins", () => { ]); await pluginRevokePermission(" community.prompt-helper ", " request.body.write "); await pluginListAuditLogs({ pluginId: " community.prompt-helper ", limit: 9999 }); + await pluginListRuntimeReports({ + pluginId: " community.prompt-helper ", + hookName: " gateway.request.afterBodyRead ", + traceId: " trace-replay-1 ", + limit: 9999, + }); + await pluginExportReplayFixture({ + pluginId: " community.prompt-helper ", + hookName: " gateway.request.afterBodyRead ", + traceId: " trace-replay-1 ", + }); expect(commands.pluginInstallFromFile).toHaveBeenCalledWith({ filePath: "/tmp/plugin.json" }); expect(commands.pluginInstallRemote).toHaveBeenCalledWith({ @@ -201,6 +259,17 @@ describe("services/plugins", () => { pluginId: "community.prompt-helper", limit: 500, }); + expect(commands.pluginListRuntimeReports).toHaveBeenCalledWith({ + pluginId: "community.prompt-helper", + hookName: "gateway.request.afterBodyRead", + traceId: "trace-replay-1", + limit: 500, + }); + expect(commands.pluginExportReplayFixture).toHaveBeenCalledWith({ + pluginId: "community.prompt-helper", + hookName: "gateway.request.afterBodyRead", + traceId: "trace-replay-1", + }); }); it("rejects empty plugin ids and file paths before IPC", async () => { diff --git a/src/services/gateway/requestLogs.ts b/src/services/gateway/requestLogs.ts index 52be05bd..ce4f4d2b 100644 --- a/src/services/gateway/requestLogs.ts +++ b/src/services/gateway/requestLogs.ts @@ -8,6 +8,7 @@ import { import type { CliKey } from "../providers/providers"; import { invokeGeneratedIpc, mapGeneratedCommandResponse } from "../generatedIpc"; import { narrowGeneratedStringUnion, type Override } from "../generatedTypeUtils"; +import { pluginExportReplayFixture, type PluginReplayFixture } from "../plugins"; const CLI_KEY_VALUES = ["claude", "codex", "gemini"] as const satisfies readonly CliKey[]; @@ -234,3 +235,16 @@ export async function requestAttemptLogsByTraceId(traceId: string, limit?: numbe ), }); } + +export async function requestLogExportPluginReplayFixture(input: { + traceId: string; + hookName: string; + pluginId?: string | null; +}): Promise { + const traceId = normalizeRequestLogTraceId(input.traceId); + return pluginExportReplayFixture({ + traceId, + hookName: input.hookName, + pluginId: input.pluginId, + }); +} diff --git a/src/services/plugins.ts b/src/services/plugins.ts index a20ae871..0bc53be5 100644 --- a/src/services/plugins.ts +++ b/src/services/plugins.ts @@ -5,11 +5,13 @@ import { type JsonValue, type PluginAuditLog, type PluginDetail, + type PluginHookExecutionReport, type PluginInstallPreview, type PluginInstallSource, type PluginManifest, type PluginMarketListing, type PluginPermissionRisk, + type PluginReplayFixture, type PluginRuntime, type PluginStatus, type PluginSummary, @@ -21,11 +23,13 @@ export type { JsonValue, PluginAuditLog, PluginDetail, + PluginHookExecutionReport, PluginInstallPreview, PluginInstallSource, PluginManifest, PluginMarketListing, PluginPermissionRisk, + PluginReplayFixture, PluginRuntime, PluginStatus, PluginSummary, @@ -34,6 +38,8 @@ export type { const PLUGIN_AUDIT_LOG_DEFAULT_LIMIT = 50; const PLUGIN_AUDIT_LOG_MAX_LIMIT = 500; +const PLUGIN_RUNTIME_REPORT_DEFAULT_LIMIT = 50; +const PLUGIN_RUNTIME_REPORT_MAX_LIMIT = 500; function normalizeRequiredText(label: string, value: string): string { const normalized = value.trim(); @@ -56,6 +62,11 @@ function clampAuditLimit(limit: number | null | undefined): number { return Math.min(PLUGIN_AUDIT_LOG_MAX_LIMIT, Math.max(1, Math.trunc(limit))); } +function clampRuntimeReportLimit(limit: number | null | undefined): number { + if (limit == null || !Number.isFinite(limit)) return PLUGIN_RUNTIME_REPORT_DEFAULT_LIMIT; + return Math.min(PLUGIN_RUNTIME_REPORT_MAX_LIMIT, Math.max(1, Math.trunc(limit))); +} + function normalizePermissions(permissions: readonly string[]): string[] { const out: string[] = []; const seen = new Set(); @@ -316,3 +327,40 @@ export async function pluginListAuditLogs(input: { invoke: async () => commands.pluginListAuditLogs({ pluginId, limit }), }); } + +export async function pluginListRuntimeReports(input: { + pluginId?: string | null; + hookName?: string | null; + traceId?: string | null; + limit?: number | null; +}) { + const pluginId = input.pluginId == null ? null : normalizePluginId(input.pluginId); + const hookName = + input.hookName == null ? null : normalizeRequiredText("hookName", input.hookName); + const traceId = input.traceId == null ? null : normalizeRequiredText("traceId", input.traceId); + const limit = clampRuntimeReportLimit(input.limit); + + return invokeGeneratedIpc({ + title: "读取插件运行报告失败", + cmd: "plugin_list_runtime_reports", + args: { pluginId, hookName, traceId, limit }, + invoke: async () => commands.pluginListRuntimeReports({ pluginId, hookName, traceId, limit }), + }); +} + +export async function pluginExportReplayFixture(input: { + traceId: string; + hookName: string; + pluginId?: string | null; +}) { + const traceId = normalizeRequiredText("traceId", input.traceId); + const hookName = normalizeRequiredText("hookName", input.hookName); + const pluginId = input.pluginId == null ? null : normalizePluginId(input.pluginId); + + return invokeGeneratedIpc({ + title: "导出插件 replay fixture 失败", + cmd: "plugin_export_replay_fixture", + args: { traceId, hookName, pluginId }, + invoke: async () => commands.pluginExportReplayFixture({ traceId, hookName, pluginId }), + }); +} From 09286132ed0dc515400da49055da98e727d21a73 Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Thu, 25 Jun 2026 20:09:59 +0800 Subject: [PATCH 081/244] feat(create-aio-plugin): add replay and publish parity --- packages/create-aio-plugin/src/devtools.ts | 101 +++++++++++++++- .../create-aio-plugin/src/scaffold.test.ts | 109 ++++++++++++++++++ 2 files changed, 205 insertions(+), 5 deletions(-) diff --git a/packages/create-aio-plugin/src/devtools.ts b/packages/create-aio-plugin/src/devtools.ts index 0a22800c..3185aec1 100644 --- a/packages/create-aio-plugin/src/devtools.ts +++ b/packages/create-aio-plugin/src/devtools.ts @@ -91,6 +91,23 @@ export type ReplayRuleTrace = { warning?: PluginDiagnostic; }; +export type PublishCheckResult = { + ok: boolean; + checksum: string; + expectedChecksum: string; + checksumVerified: boolean; + signatureVerified: boolean; + unsigned: boolean; + manifestId: string; + name: string; + version: string; + runtime: PluginManifest["runtime"]["kind"]; + permissions: string[]; + hooks: string[]; + hostCompatibility: PluginManifest["hostCompatibility"]; + sizeBytes: number; +}; + type DoctorOptions = { strict?: boolean; }; @@ -153,6 +170,7 @@ const USAGE = [ " create-aio-plugin validate [--strict] ", " create-aio-plugin replay [--explain] ", " create-aio-plugin pack ", + " create-aio-plugin publish-check ", ].join("\n"); export function runCreateAioPluginCli(args: string[], cwd: string, io: CliIo = console): number { @@ -252,6 +270,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 = JSON.parse(manifestText) as Partial; + 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 @@ -1490,6 +1531,43 @@ export function verifyPackage( }; } +export function publishCheckPluginBytes( + bytes: Uint8Array, + input: { + checksum: string; + signature?: string | null; + publicKey?: string | null; + manifest: string; + } +): PublishCheckResult { + const manifest = JSON.parse(input.manifest) as PluginManifest; + const validation = validateManifest(manifest); + 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; + + return { + ok: validation.ok && checksumVerified && (unsigned || signatureVerified), + checksum, + expectedChecksum: input.checksum, + checksumVerified, + signatureVerified, + unsigned, + manifestId: manifest.id, + name: manifest.name, + version: manifest.version, + runtime: manifest.runtime.kind, + permissions: [...manifest.permissions], + hooks: manifest.hooks.map((hook) => hook.name), + hostCompatibility: manifest.hostCompatibility, + sizeBytes: bytes.length, + }; +} + function createPublicKeyFromPrivateKey(privateKey: string): string { return signPackage(new Uint8Array(), privateKey).publicKey; } @@ -1734,25 +1812,38 @@ 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 direct = textFromFixtureValue(contextRecord?.body); + if (direct) return direct; const request = asRecord(contextRecord?.request); - return typeof request?.body === "string" ? request.body : undefined; + const body = textFromFixtureValue(request?.body); + if (body) return body; + return Array.isArray(request?.normalizedMessages) + ? JSON.stringify({ messages: request.normalizedMessages }) + : undefined; } if (field === "response.body") { const response = asRecord(contextRecord?.response); - return typeof response?.body === "string" ? response.body : undefined; + return textFromFixtureValue(response?.body); } if (field === "stream.chunk") { const stream = asRecord(contextRecord?.stream); - return typeof stream?.chunk === "string" ? stream.chunk : undefined; + return textFromFixtureValue(stream?.chunk); } if (field === "log.message") { const log = asRecord(contextRecord?.log); - return typeof log?.message === "string" ? log.message : undefined; + return textFromFixtureValue(log?.message) ?? textFromFixtureValue(log?.body); } return undefined; } +function textFromFixtureValue(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (value == null) return undefined; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (typeof value === "object") return JSON.stringify(value); + return undefined; +} + function replayRegexMatches(regex: RegExp, value: string): boolean { const matched = regex.test(value); regex.lastIndex = 0; diff --git a/packages/create-aio-plugin/src/scaffold.test.ts b/packages/create-aio-plugin/src/scaffold.test.ts index a07f33d7..c0d0bff5 100644 --- a/packages/create-aio-plugin/src/scaffold.test.ts +++ b/packages/create-aio-plugin/src/scaffold.test.ts @@ -12,6 +12,7 @@ import { packPlugin, packPluginBytes, packPluginDirectory, + publishCheckPluginBytes, replayHook, replayHookExplain, runCreateAioPluginCli, @@ -132,6 +133,33 @@ describe("create-aio-plugin scaffold", () => { }); }); + it("publish-check emits package metadata needed by the market flow", () => { + const files = createPluginScaffold({ + id: "acme.redactor", + name: "Redactor", + template: "rule", + }); + 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: "declarativeRules", + }); + }); + it("signs and verifies package bytes through the CLI helper", () => { const keyPair = generateSigningKeyPair(); const signedOutput: string[] = []; @@ -197,6 +225,41 @@ describe("create-aio-plugin scaffold", () => { expect(existsSync(result.path)).toBe(true); }); + it("publish-check command emits release metadata without writing the artifact", () => { + const cwd = mkdtempSync(join(tmpdir(), "aio-plugin-publish-check-")); + writeScaffold( + join(cwd, "acme.redactor"), + createPluginScaffold({ + id: "acme.redactor", + name: "Redactor", + template: "rule", + }) + ); + const output: string[] = []; + + expect( + runCreateAioPluginCli(["publish-check", "./acme.redactor"], cwd, { + log: (line) => output.push(line), + error: () => undefined, + }) + ).toBe(0); + + const result = JSON.parse(output[0] ?? "{}") as { + artifactPath: string; + checksum: string; + manifestId: string; + signatureVerified: boolean; + }; + + expect(result).toMatchObject({ + artifactPath: join(cwd, "acme.redactor.aio-plugin"), + manifestId: "acme.redactor", + signatureVerified: false, + }); + expect(result.checksum).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(existsSync(result.artifactPath)).toBe(false); + }); + 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" })); @@ -1623,6 +1686,52 @@ describe("create-aio-plugin scaffold", () => { expect(JSON.stringify(result)).toContain("[REDACTED]"); }); + it("replay explain accepts exported host fixtures without changing the host contract shape", () => { + const fixture = { + schemaVersion: 1, + source: { + appVersion: "0.62.3", + traceId: "trace-replay-1", + exportedAtMs: 1_000, + requestLogId: 1, + createdAtMs: 900, + }, + traceId: "trace-replay-1", + hookName: "gateway.request.afterBodyRead", + pluginId: "acme.redactor", + request: { + cliKey: "codex", + method: "POST", + path: "/v1/responses", + body: { messages: [{ role: "user", content: "SECRET_TOKEN" }] }, + normalizedMessages: [{ role: "user", content: "SECRET_TOKEN" }], + }, + response: null, + log: null, + attempts: [], + runtimeReports: [], + notes: [], + }; + + const result = replayHookExplain( + rulePluginFilesWithTarget("$.messages[*].content"), + "gateway.request.afterBodyRead", + fixture + ); + + expect(result).toMatchObject({ + pluginId: "acme.redactor", + outputKind: "replace", + mutationSummary: { + changed: true, + field: "requestBody", + targetField: "request.body", + jsonPath: "$.messages[*].content", + }, + }); + expect(JSON.stringify(result)).toContain("[REDACTED]"); + }); + it("replay explain supports Rust regex inline flags accepted by strict validation", () => { const files = rulePluginFilesWithTarget(undefined); const document = JSON.parse(files["rules/main.json"] ?? "{}") as { From 0552e4e006ba29660f83be0426b0ea9f5705da5c Mon Sep 17 00:00:00 2001 From: dyndynjyxa Date: Thu, 25 Jun 2026 20:21:27 +0800 Subject: [PATCH 082/244] feat(plugins): productize observability and market UI --- docs/plugins/examples/README.md | 11 ++ docs/plugins/examples/privacy-filter.md | 10 ++ src/pages/PluginsPage.tsx | 36 +++++ src/pages/__tests__/PluginsPage.test.tsx | 121 ++++++++++++++++ src/pages/plugins/PluginMarketPanel.tsx | 172 +++++++++++++++++++++++ 5 files changed, 350 insertions(+) create mode 100644 src/pages/plugins/PluginMarketPanel.tsx diff --git a/docs/plugins/examples/README.md b/docs/plugins/examples/README.md index 105b8ea8..0c7c50b3 100644 --- a/docs/plugins/examples/README.md +++ b/docs/plugins/examples/README.md @@ -3,3 +3,14 @@ 这里放官方示例和推荐社区插件形态。示例的目标是展示插件系统应该怎样被使用,而不是扩展宿主内置插件数量。 - [Privacy Filter](./privacy-filter.md):当前唯一内置官方插件 `official.privacy-filter`,对齐 `packyme/privacy-filter` 的核心脱敏能力。 + +## 示例清单 + +| 示例 ID | 目标 | Hooks | Permissions | Fixtures / 覆盖路径 | +| --- | --- | --- | --- | --- | +| `official.privacy-filter` | 请求和日志脱敏 | `gateway.request.afterBodyRead`, `gateway.request.beforeSend`, `log.beforePersist` | `request.body.read`, `request.body.write`, `log.redact` | 官方 fixture 存在于宿主资源目录;覆盖配置 UI、request replay export 和日志脱敏边界 | +| `examples/prompt-helper` | 在请求进入 provider 前补充提示词约束 | `gateway.request.afterBodyRead` | `request.body.read`, `request.body.write` | 应包含 Claude messages 和 OpenAI/Codex Responses fixture;覆盖 trace replay 后的请求 mutation | +| `examples/redactor` | 展示社区 declarativeRules 脱敏形态 | `gateway.request.beforeSend`, `log.beforePersist` | `request.body.read`, `request.body.write`, `log.redact` | 应包含命中和未命中的 replay fixture;覆盖 pack、publish-check 和市场安装元数据 | +| `examples/response-guard` | 在响应返回后做轻量检查或标记 | `gateway.response.beforeSend` | `response.body.read`, `response.body.write` | 应包含 streamed/non-streamed 响应 fixture;覆盖失败策略、运行诊断和 replay notes | + +这些示例都保持在 Plugin API v1 范围内。宿主负责运行诊断、fixture 导出、安装校验和市场索引解析;插件只声明 manifest、hooks、permissions 和自己的规则或运行时代码。 diff --git a/docs/plugins/examples/privacy-filter.md b/docs/plugins/examples/privacy-filter.md index 90975014..87d973d4 100644 --- a/docs/plugins/examples/privacy-filter.md +++ b/docs/plugins/examples/privacy-filter.md @@ -10,6 +10,8 @@ 用户可以在 Plugins 页面通过官方插件安装入口安装它。 +Plugins 页面也会展示 `examples/prompt-helper`、`examples/redactor` 和 `examples/response-guard` 作为社区示例方向。它们不是 bundled official plugin,也不会绕过宿主的安装、权限、兼容性或签名校验。 + ## Privacy Filter ID: `official.privacy-filter` @@ -64,9 +66,17 @@ Official privacy filter rules are loaded under a 1 MiB host byte budget. Communi - 一个 package command。 - 精确列出它请求的 permissions。 - 简短说明哪些行为是 intentionally unsupported。 +- 能被宿主导出的 trace replay fixture 覆盖至少一个正常路径和一个边界路径。 +- 能通过 `create-aio-plugin publish-check` 生成市场发布 metadata。 社区示例应优先使用 `declarativeRules`。只有当行为需要确定性代码执行且规则运行时无法表达时,才考虑 WASM。WASM examples 可以展示 ABI packaging,但 gateway execution 在宿主启用前仍受策略限制。 +## Replay 与发布流程 + +`official.privacy-filter` 可以用宿主导出的 replay fixture 验证请求脱敏和日志脱敏边界。当前 request logs 不持久化完整 request/response body,所以导出的 fixture 会携带 trace、attempts、运行报告和 notes;插件作者需要用本地 fixture 补齐需要复现的 body。 + +发布到市场前,插件包仍应经过 `pack`、`sign` 或 `verify` 以及 `publish-check`。`publish-check` 只生成发布 metadata,不替代宿主安装时的 checksum、signature、兼容性和撤销状态检查。 + ## 已移除的内置示例 早期草案包含 built-in prompt optimizer、safety detector 和 generic redactor examples。它们不再作为官方插件内置。 diff --git a/src/pages/PluginsPage.tsx b/src/pages/PluginsPage.tsx index 280f824f..10f0b348 100644 --- a/src/pages/PluginsPage.tsx +++ b/src/pages/PluginsPage.tsx @@ -34,6 +34,7 @@ import { usePluginGrantPermissionsMutation, usePluginInstallFromFileMutation, usePluginInstallOfficialMutation, + usePluginInstallRemoteMutation, usePluginPreviewFromFileMutation, usePluginPreviewUpdateFromFileMutation, usePluginExportReplayFixtureMutation, @@ -48,6 +49,7 @@ import { import { PluginConfigSchemaForm } from "./plugins/PluginConfigSchemaForm"; import { PluginInstallPreviewDialog } from "./plugins/PluginInstallPreviewDialog"; import { PluginLifecyclePanel } from "./plugins/PluginLifecyclePanel"; +import { PluginMarketPanel } from "./plugins/PluginMarketPanel"; import { PluginUpdatePreviewDialog } from "./plugins/PluginUpdatePreviewDialog"; import { describePluginPermission, @@ -58,6 +60,13 @@ import { const OFFICIAL_PLUGINS = [{ id: "official.privacy-filter", name: "Privacy Filter" }]; +const EXAMPLE_PLUGINS = [ + { id: "official.privacy-filter", label: "官方脱敏" }, + { id: "examples/prompt-helper", label: "请求提示词辅助" }, + { id: "examples/redactor", label: "规则脱敏" }, + { id: "examples/response-guard", label: "响应检查" }, +]; + const INSTALL_SOURCE_LABELS: Record = { local: "本地", market: "市场", @@ -619,6 +628,7 @@ export function PluginsPage() { const previewUpdateMutation = usePluginPreviewUpdateFromFileMutation(); const installMutation = usePluginInstallFromFileMutation(); const installOfficialMutation = usePluginInstallOfficialMutation(); + const installRemoteMutation = usePluginInstallRemoteMutation(); const updateMutation = usePluginUpdateFromFileMutation(); const rollbackMutation = usePluginRollbackMutation(); const enableMutation = usePluginEnableMutation(); @@ -643,6 +653,7 @@ export function PluginsPage() { previewUpdateMutation.isPending || installMutation.isPending || installOfficialMutation.isPending || + installRemoteMutation.isPending || updateMutation.isPending || rollbackMutation.isPending || enableMutation.isPending || @@ -755,6 +766,31 @@ export function PluginsPage() { })}
+
+ + runAction("安装市场插件", () => installRemoteMutation.mutateAsync(input)) + } + /> +
+
+

示例插件

+
+ 覆盖脱敏、提示词辅助、响应检查和 replay fixture。 +
+
+
+ {EXAMPLE_PLUGINS.map((example) => ( +
+
{example.id}
+
{example.label}
+
+ ))} +
+
+
+ {plugins.length === 0 && !listQuery.error ? (
还没有安装插件
diff --git a/src/pages/__tests__/PluginsPage.test.tsx b/src/pages/__tests__/PluginsPage.test.tsx index 6947efa2..73ba9ae2 100644 --- a/src/pages/__tests__/PluginsPage.test.tsx +++ b/src/pages/__tests__/PluginsPage.test.tsx @@ -13,6 +13,7 @@ import type { PluginSummary, PluginUpdateDiff, } from "../../services/plugins"; +import { pluginParseMarketIndex } from "../../services/plugins"; import { openDesktopSinglePath } from "../../services/desktop/dialog"; import { createTestQueryClient } from "../../test/utils/reactQuery"; import { @@ -21,6 +22,7 @@ import { usePluginGrantPermissionsMutation, usePluginInstallFromFileMutation, usePluginInstallOfficialMutation, + usePluginInstallRemoteMutation, usePluginPreviewFromFileMutation, usePluginPreviewUpdateFromFileMutation, usePluginExportReplayFixtureMutation, @@ -51,6 +53,15 @@ vi.mock("../../services/desktop/dialog", async () => { vi.mock("../../services/clipboard", () => ({ copyText: vi.fn().mockResolvedValue(undefined) })); +vi.mock("../../services/plugins", async () => { + const actual = + await vi.importActual("../../services/plugins"); + return { + ...actual, + pluginParseMarketIndex: vi.fn(), + }; +}); + vi.mock("../../query/plugins", async () => { const actual = await vi.importActual("../../query/plugins"); return { @@ -59,6 +70,7 @@ vi.mock("../../query/plugins", async () => { usePluginQuery: vi.fn(), usePluginInstallFromFileMutation: vi.fn(), usePluginInstallOfficialMutation: vi.fn(), + usePluginInstallRemoteMutation: vi.fn(), usePluginPreviewFromFileMutation: vi.fn(), usePluginPreviewUpdateFromFileMutation: vi.fn(), usePluginExportReplayFixtureMutation: vi.fn(), @@ -325,6 +337,7 @@ describe("pages/PluginsPage", () => { ); vi.mocked(usePluginInstallFromFileMutation).mockReturnValue(mutation() as any); vi.mocked(usePluginInstallOfficialMutation).mockReturnValue(mutation() as any); + vi.mocked(usePluginInstallRemoteMutation).mockReturnValue(mutation() as any); vi.mocked(usePluginUpdateFromFileMutation).mockReturnValue(mutation() as any); vi.mocked(usePluginRollbackMutation).mockReturnValue(mutation() as any); vi.mocked(usePluginEnableMutation).mockReturnValue(mutation() as any); @@ -462,6 +475,114 @@ describe("pages/PluginsPage", () => { }); }); + it("renders market state and disables revoked or incompatible installs", async () => { + const installRemoteMutation = mutation(); + vi.mocked(usePluginInstallRemoteMutation).mockReturnValue(installRemoteMutation as any); + vi.mocked(pluginParseMarketIndex).mockResolvedValue([ + { + pluginId: "community.safe-helper", + name: "Safe Helper", + latestVersion: "1.0.0", + downloadUrl: "https://plugins.example.test/safe-helper.aio-plugin", + checksum: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + signature: "signed-safe", + riskLabels: ["request.body.read"], + revoked: false, + compatible: true, + updateAvailable: false, + installBlockReason: null, + }, + { + pluginId: "community.revoked", + name: "Revoked Helper", + latestVersion: "1.0.0", + downloadUrl: "https://plugins.example.test/revoked.aio-plugin", + checksum: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + signature: null, + riskLabels: ["request.body.write"], + revoked: true, + compatible: true, + updateAvailable: false, + installBlockReason: "插件已被市场撤销", + }, + { + pluginId: "community.future", + name: "Future Helper", + latestVersion: "2.0.0", + downloadUrl: "https://plugins.example.test/future.aio-plugin", + checksum: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + signature: null, + riskLabels: ["response.body.write"], + revoked: false, + compatible: false, + updateAvailable: false, + installBlockReason: "需要更高版本的宿主", + }, + ]); + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary()], + isLoading: false, + isFetching: false, + error: null, + } as any); + + renderWithProviders(); + fireEvent.change(screen.getByLabelText("市场索引 JSON"), { + target: { value: '{"plugins":[]}' }, + }); + fireEvent.change(screen.getByLabelText("市场索引 URL"), { + target: { value: "https://plugins.example.test/index.json" }, + }); + fireEvent.click(screen.getByRole("button", { name: "加载市场" })); + + const safeListing = await screen.findByText("Safe Helper"); + const revokedListing = screen.getByText("Revoked Helper").closest("article"); + const futureListing = screen.getByText("Future Helper").closest("article"); + expect(safeListing).toBeInTheDocument(); + expect(revokedListing).not.toBeNull(); + expect(futureListing).not.toBeNull(); + expect(screen.getByText("插件已被市场撤销")).toBeInTheDocument(); + expect(screen.getByText("需要更高版本的宿主")).toBeInTheDocument(); + expect( + within(revokedListing as HTMLElement).getByRole("button", { name: "安装" }) + ).toBeDisabled(); + expect( + within(futureListing as HTMLElement).getByRole("button", { name: "安装" }) + ).toBeDisabled(); + + fireEvent.click( + within(safeListing.closest("article") as HTMLElement).getByRole("button", { name: "安装" }) + ); + + await waitFor(() => { + expect(installRemoteMutation.mutateAsync).toHaveBeenCalledWith({ + pluginId: "community.safe-helper", + downloadUrl: "https://plugins.example.test/safe-helper.aio-plugin", + checksum: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + signature: "signed-safe", + publicKey: null, + source: "market", + }); + expect(toast.success).toHaveBeenCalledWith("安装市场插件成功"); + }); + }); + + it("keeps privacy filter, prompt helper, redactor, and response guard example guidance visible", () => { + vi.mocked(usePluginsListQuery).mockReturnValue({ + data: [summary()], + isLoading: false, + isFetching: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText("official.privacy-filter")).toBeInTheDocument(); + expect(screen.getByText("examples/prompt-helper")).toBeInTheDocument(); + expect(screen.getByText("examples/redactor")).toBeInTheDocument(); + expect(screen.getByText("examples/response-guard")).toBeInTheDocument(); + }); + it("uses only the latest lifecycle audit for trust state", () => { vi.mocked(usePluginsListQuery).mockReturnValue({ data: [summary()], diff --git a/src/pages/plugins/PluginMarketPanel.tsx b/src/pages/plugins/PluginMarketPanel.tsx new file mode 100644 index 00000000..89b06e7e --- /dev/null +++ b/src/pages/plugins/PluginMarketPanel.tsx @@ -0,0 +1,172 @@ +// Usage: Minimal plugin market source parser and listing installer. + +import { useState } from "react"; +import { Download, RefreshCw } from "lucide-react"; +import type { PluginMarketListing } from "../../services/plugins"; +import { pluginParseMarketIndex } from "../../services/plugins"; +import { formatUnknownError } from "../../utils/errors"; +import { Button } from "../../ui/Button"; + +type MarketInstallInput = { + pluginId: string; + downloadUrl: string; + checksum: string; + signature?: string | null; + publicKey?: string | null; + source: "market"; +}; + +export function PluginMarketPanel({ + busy, + onInstall, +}: { + busy: boolean; + onInstall: (input: MarketInstallInput) => Promise; +}) { + const [indexUrl, setIndexUrl] = useState(""); + const [indexJson, setIndexJson] = useState(""); + const [signature, setSignature] = useState(""); + const [listings, setListings] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleLoadMarket() { + setLoading(true); + setError(null); + try { + const next = await pluginParseMarketIndex( + indexJson, + indexUrl.trim() ? indexUrl : null, + signature.trim() ? signature : null + ); + setListings(next); + } catch (error) { + setError(formatUnknownError(error)); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

插件市场

+
加载索引后安装或更新市场插件。
+
+ +
+ +
+ + +
+ +