From c400c8f7a11cbfff29a5e1a68aac61c7681a0045 Mon Sep 17 00:00:00 2001 From: coso Date: Wed, 5 Aug 2026 18:10:06 +0800 Subject: [PATCH] Release v1.121.0 --- RELEASE_NOTES.en.md | 24 +- RELEASE_NOTES.md | 24 +- .../.agents/plugins/marketplace.json | 20 + .../browser/skills/control-browser/SKILL.md | 13 + electron/appServerHost.ts | 29 + electron/embeddedBrowserHost.test.ts | 211 +- electron/embeddedBrowserHost.ts | 114 +- electron/embeddedBrowserHtml.test.ts | 89 + electron/embeddedBrowserHtml.ts | 197 ++ electron/ipcChannels.test.ts | 1 + electron/ipcChannels.ts | 1 + forge.config.mjs | 1 + internal/aiprompts/architecture.md | 41 + internal/exec-plans/README.md | 2 + .../refactor-v2-test-phase-2-plan.md | 20 + internal/exec-plans/release-v1.121.0-plan.md | 69 + internal/roadmap/browser/README.md | 8 +- internal/roadmap/plugin/README.md | 113 +- .../roadmap/plugin/v2/00-research-findings.md | 238 +++ .../roadmap/plugin/v2/01-product-contract.md | 227 ++ .../v2/02-package-marketplace-installation.md | 293 +++ .../03-architecture-and-command-contracts.md | 285 +++ .../v2/04-app-center-and-claw-surfaces.md | 242 +++ .../plugin/v2/05-migration-and-cleanup.md | 170 ++ .../plugin/v2/06-implementation-plan.md | 245 +++ .../plugin/v2/07-verification-contract.md | 316 +++ .../roadmap/plugin/v2/08-legacy-synthesis.md | 188 ++ internal/roadmap/plugin/v2/README.md | 82 + lime-rs/Cargo.lock | 61 +- lime-rs/Cargo.toml | 2 +- lime-rs/crates/agent-protocol/src/thread.rs | 4 + .../current_provider_turn/tool_executor.rs | 2 + .../tool_lifecycle_emitter.rs | 11 +- lime-rs/crates/agent/src/mcp_bridge.rs | 4 +- lime-rs/crates/agent/src/runtime_state.rs | 13 + .../agent/src/runtime_state/mcp_runtime.rs | 20 +- .../src/runtime_state/mcp_runtime_tests.rs | 106 + lime-rs/crates/app-server-client/src/lib.rs | 4 + .../json/app_server_protocol.schemas.json | 1274 ++++++++++++ .../schema/json/manifest.json | 40 + .../schema/json/v0/McpResourceReadParams.json | 14 + .../json/v0/McpResourceReadResponse.json | 1 + .../schema/json/v2/ClientRequest.json | 213 ++ .../schema/json/v2/Method.json | 6 + .../json/v2/PluginCatalogCapability.json | 25 + .../schema/json/v2/PluginCatalogDetail.json | 202 ++ .../v2/PluginCatalogEnabledSetParams.json | 17 + .../v2/PluginCatalogEnabledSetResponse.json | 110 + .../schema/json/v2/PluginCatalogHook.json | 17 + .../json/v2/PluginCatalogInstallParams.json | 31 + .../json/v2/PluginCatalogInstallResponse.json | 110 + .../json/v2/PluginCatalogInstalledParams.json | 5 + .../json/v2/PluginCatalogListParams.json | 26 + .../json/v2/PluginCatalogListResponse.json | 117 ++ .../json/v2/PluginCatalogReadParams.json | 13 + .../json/v2/PluginCatalogReadResponse.json | 213 ++ .../schema/json/v2/PluginCatalogSummary.json | 97 + .../json/v2/PluginCatalogUiResource.json | 21 + .../json/v2/PluginCatalogUninstallParams.json | 13 + .../v2/PluginCatalogUninstallResponse.json | 17 + .../src/protocol/v0/mcp.rs | 8 + .../src/protocol/v2/envelopes.rs | 46 +- .../src/protocol/v2/methods.rs | 36 + .../src/protocol/v2/mod.rs | 2 + .../src/protocol/v2/plugin.rs | 141 ++ .../src/protocol/v2/schema_types.rs | 16 + .../src/schema_export/registry.rs | 16 + lime-rs/crates/app-server/Cargo.toml | 1 + .../app-server/src/local_data_source.rs | 1 + .../src/local_data_source/impls/mcp.rs | 2 +- .../src/local_data_source/impls/plugins.rs | 61 + .../app-server/src/local_data_source/mcp.rs | 32 +- .../src/local_data_source/plugin_catalog.rs | 1218 +++++++++++ .../local_data_source/plugin_catalog/tests.rs | 311 +++ .../app-server/src/processor/dispatch.rs | 18 + .../src/processor/dispatch/v2_ingress.rs | 36 +- .../crates/app-server/src/processor/plugin.rs | 88 + .../src/processor/thread/projection.rs | 6 +- .../src/processor/thread/projection/tests.rs | 15 +- lime-rs/crates/app-server/src/runtime.rs | 9 + .../src/runtime/app_data/plugins.rs | 53 + .../src/runtime/canonical_thread_store.rs | 4 + .../canonical_thread_store/persistence.rs | 28 +- .../runtime/canonical_thread_store_tests.rs | 195 ++ .../codex/canonical_items.rs | 2 + .../evidence_provider/canonical_tool.rs | 1 + lime-rs/crates/app-server/src/runtime/mcp.rs | 12 +- .../src/runtime/output_refs/tests.rs | 2 + .../crates/app-server/src/runtime/plugins.rs | 57 + .../src/runtime/provider_history/canonical.rs | 2 + .../src/runtime/read_model/canonical_items.rs | 8 + .../src/runtime/tests/agent_control/fork.rs | 2 + .../src/runtime/thread_item_projection.rs | 2 +- .../thread_item_projection/change_set.rs | 18 +- .../materializer/lowering.rs | 5 + .../typed_tests/incremental.rs | 91 + .../crates/app-server/src/runtime_backend.rs | 28 +- .../src/runtime_backend/execution_backend.rs | 41 +- .../runtime_backend/initialization_tests.rs | 24 + .../src/runtime_backend/mention_selection.rs | 228 +- .../src/runtime_backend/request_context.rs | 2 + .../request_context/session_config.rs | 41 +- .../tests/session_skill_context.rs | 89 + .../crates/app-server/src/skills_watcher.rs | 189 +- lime-rs/crates/mcp/src/manager.rs | 3 + lime-rs/crates/mcp/src/manager/resources.rs | 7 +- .../crates/mcp/src/manager/tests/resources.rs | 39 + lime-rs/crates/mcp/src/types.rs | 1 + .../crates/thread-store/src/history_merge.rs | 51 +- .../crates/thread-store/src/history_tests.rs | 61 + .../crates/tool-runtime/src/mcp_connection.rs | 26 + .../src/mcp_connection/step_snapshot.rs | 86 +- .../crates/tool-runtime/src/mcp_resource.rs | 3 + .../tool-runtime/src/native_dispatch.rs | 1 + package.json | 3 +- .../src/generated/protocol-types.ts | 168 ++ .../src/request-client-methods.ts | 36 + .../app-server-client/src/request-client.ts | 18 + packages/lime-cli-npm/package.json | 2 +- .../tool-execution-soak-evidence.mjs | 42 +- .../tool-execution-soak-evidence.test.mjs | 36 + scripts/electron/build-renderer-smoke.mjs | 8 +- scripts/electron/build-renderer.mjs | 8 +- scripts/electron/copy-desktop-assets.mjs | 12 +- scripts/electron/current-entrypoints.test.mjs | 2 + scripts/electron/forge-config.test.mjs | 6 + scripts/electron/mcp-elicitation-gate-b.mjs | 1840 ++++++++++++++++- .../plugin-v2-current-electron-fixture.mjs | 13 + ...lugin-v2-current-electron-fixture.test.mjs | 118 ++ scripts/electron/plugin-v2-mcp-app-gate-b.mjs | 475 +++++ scripts/electron/renderer-build-env.mjs | 69 + scripts/lib/electron-fixture-build.mjs | 15 +- src/components/AppPageContent.test.tsx | 12 +- src/components/AppPageContent.tsx | 8 +- ...atWorkspace.workspaceBoundaryGuard.test.ts | 2 +- .../FileManager/FileManagerSidebar.test.tsx | 46 + .../FileManager/FileManagerSidebar.tsx | 45 +- .../Inputbar/hooks/useInputbarSend.test.tsx | 17 +- .../Inputbar/hooks/useInputbarSend.ts | 8 +- .../Inputbar/pluginInputCapability.ts | 31 +- .../pluginInputCapability.unit.test.ts | 14 + .../agent/chat/hooks/agentChatShared.ts | 9 +- .../chat/hooks/agentStreamSubmitExecution.ts | 11 +- ...gentStreamUserInputSendPreparation.test.ts | 14 + .../agentStreamUserInputSendPreparation.ts | 6 + .../hooks/agentStreamUserInputSubmission.ts | 2 + .../agent/chat/hooks/handleSendTypes.ts | 2 + .../sessionSwitchSnapshotController.test.ts | 55 + .../hooks/sessionSwitchSnapshotController.ts | 28 + .../preferencesSnapshotHydration.case.tsx | 90 + .../agent/chat/hooks/useAgentSession.ts | 53 +- src/components/agent/chat/index.test.tsx | 2 + .../chat/utils/buildUserInputSubmitOp.test.ts | 14 + .../chat/utils/buildUserInputSubmitOp.ts | 19 +- .../workspace/WorkspacePluginSurface.test.tsx | 370 +++- .../chat/workspace/WorkspacePluginSurface.tsx | 191 +- .../WorkspaceRightSurfaceHostRuntime.tsx | 7 + .../workspace/agentChatWorkspaceHelpers.ts | 19 + .../useAgentChatWorkspaceSceneRuntime.tsx | 14 +- .../useAgentChatWorkspaceSetupRuntime.ts | 4 + .../useTaskCenterDraftSendRuntime.ts | 7 +- ...useTaskCenterDraftSendRuntime.unit.test.ts | 19 + .../useWorkspaceExpertSkillPanelRuntime.ts | 26 +- ...WorkspaceInitialSessionNavigation.test.tsx | 4 +- .../useWorkspaceInitialSessionNavigation.ts | 4 +- .../useWorkspacePluginCatalogSuggestions.ts | 94 + ...pacePluginCatalogSuggestions.unit.test.tsx | 105 + .../useWorkspaceRightSurfaceActionRuntime.ts | 21 +- ...WorkspaceRightSurfaceCoordinatorRuntime.ts | 102 +- ...eWorkspaceRightSurfaceLocalStateRuntime.ts | 6 + .../chat/workspace/useWorkspaceSendActions.ts | 3 + .../chat/workspace/workspaceMcpAppResource.ts | 83 + .../workspaceMcpAppResource.unit.test.ts | 63 + .../workspacePluginCatalogSuggestions.ts | 18 + ...spacePluginCatalogSuggestions.unit.test.ts | 55 + .../workspace/workspacePluginSurfaceModel.ts | 119 +- .../workspacePluginSurfaceModel.unit.test.ts | 190 ++ .../plugin/ui/PluginAppCenterList.tsx | 141 +- .../plugin/ui/PluginCatalogPage.test.tsx | 200 ++ src/features/plugin/ui/PluginCatalogPage.tsx | 916 ++++++++ .../plugin/ui/PluginCatalogPageViewModel.ts | 62 + .../PluginCatalogPageViewModel.unit.test.ts | 77 + src/features/plugin/ui/PluginsPage.test.tsx | 69 +- src/i18n/__tests__/loadNamespace.test.ts | 2 - src/i18n/resources/en-US/agent.json | 76 +- src/i18n/resources/ja-JP/agent.json | 76 +- src/i18n/resources/ko-KR/agent.json | 76 +- src/i18n/resources/zh-CN/agent.json | 76 +- src/i18n/resources/zh-TW/agent.json | 76 +- .../appServerCanonicalItemReader.test.ts | 2 + .../appServerCanonicalItemReader.ts | 1 + src/lib/api/appServerClientMethodSpecs.ts | 30 + src/lib/api/appServerClientMethods.ts | 30 + src/lib/api/appServerConstants.ts | 9 + src/lib/api/appServerTypes.ts | 25 + src/lib/api/embeddedBrowser.ts | 29 +- src/lib/api/mcp.test.ts | 41 + src/lib/api/mcp.ts | 19 +- src/lib/api/mcpResponseGuards.ts | 6 +- src/lib/api/mcpTypes.ts | 1 + src/lib/api/pluginCatalog.test.ts | 89 + src/lib/api/pluginCatalog.ts | 96 + .../codexModelNativeToolPolicyOrigin.test.ts | 7 +- 203 files changed, 16095 insertions(+), 557 deletions(-) create mode 100644 assets/plugins/openai-bundled/.agents/plugins/marketplace.json create mode 100644 assets/plugins/openai-bundled/plugins/browser/skills/control-browser/SKILL.md create mode 100644 electron/embeddedBrowserHtml.test.ts create mode 100644 electron/embeddedBrowserHtml.ts create mode 100644 internal/exec-plans/release-v1.121.0-plan.md create mode 100644 internal/roadmap/plugin/v2/00-research-findings.md create mode 100644 internal/roadmap/plugin/v2/01-product-contract.md create mode 100644 internal/roadmap/plugin/v2/02-package-marketplace-installation.md create mode 100644 internal/roadmap/plugin/v2/03-architecture-and-command-contracts.md create mode 100644 internal/roadmap/plugin/v2/04-app-center-and-claw-surfaces.md create mode 100644 internal/roadmap/plugin/v2/05-migration-and-cleanup.md create mode 100644 internal/roadmap/plugin/v2/06-implementation-plan.md create mode 100644 internal/roadmap/plugin/v2/07-verification-contract.md create mode 100644 internal/roadmap/plugin/v2/08-legacy-synthesis.md create mode 100644 internal/roadmap/plugin/v2/README.md create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogCapability.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogDetail.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogEnabledSetParams.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogEnabledSetResponse.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogHook.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstallParams.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstallResponse.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstalledParams.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogListParams.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogListResponse.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogReadParams.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogReadResponse.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogSummary.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUiResource.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUninstallParams.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUninstallResponse.json create mode 100644 lime-rs/crates/app-server-protocol/src/protocol/v2/plugin.rs create mode 100644 lime-rs/crates/app-server/src/local_data_source/plugin_catalog.rs create mode 100644 lime-rs/crates/app-server/src/local_data_source/plugin_catalog/tests.rs create mode 100644 scripts/electron/plugin-v2-current-electron-fixture.mjs create mode 100644 scripts/electron/plugin-v2-current-electron-fixture.test.mjs create mode 100644 scripts/electron/plugin-v2-mcp-app-gate-b.mjs create mode 100644 src/components/agent/chat/workspace/useWorkspacePluginCatalogSuggestions.ts create mode 100644 src/components/agent/chat/workspace/useWorkspacePluginCatalogSuggestions.unit.test.tsx create mode 100644 src/components/agent/chat/workspace/workspaceMcpAppResource.ts create mode 100644 src/components/agent/chat/workspace/workspaceMcpAppResource.unit.test.ts create mode 100644 src/components/agent/chat/workspace/workspacePluginCatalogSuggestions.ts create mode 100644 src/components/agent/chat/workspace/workspacePluginCatalogSuggestions.unit.test.ts create mode 100644 src/features/plugin/ui/PluginCatalogPage.test.tsx create mode 100644 src/features/plugin/ui/PluginCatalogPage.tsx create mode 100644 src/features/plugin/ui/PluginCatalogPageViewModel.ts create mode 100644 src/features/plugin/ui/PluginCatalogPageViewModel.unit.test.ts create mode 100644 src/lib/api/pluginCatalog.test.ts create mode 100644 src/lib/api/pluginCatalog.ts diff --git a/RELEASE_NOTES.en.md b/RELEASE_NOTES.en.md index 9c39d9919..0cd59bb28 100644 --- a/RELEASE_NOTES.en.md +++ b/RELEASE_NOTES.en.md @@ -1,23 +1,37 @@ -## Lime v1.120.1 +## Lime v1.121.0 Simplified Chinese release notes are the primary version. ### New Features +- Introduced the Plugin v2 current path. App Center can discover Codex-compatible plugins from bundled, repository, personal, and local-directory catalogs, then review, install, enable, disable, and uninstall them. +- Enabled installed plugins in the current Agent runtime through immutable activation snapshots, including structured `plugin://` mentions in Claw, Skill/MCP injection, MCP App Right Surface rendering, and history restoration. +- Added a controlled Electron WebContentsView HTML host and bundled Browser Plugin assets, and included plugin assets in the Forge packaging path. + ### Fixes -- Prevented internal provenance metadata from ordinary runtime Thread Items crossing the v2 projection boundary, so public items such as `contextCompaction` no longer expose private fields such as `sourceEventType`; imported history keeps its controlled typed metadata. +- Fixed Plugin MCP provider namespaces being treated as canonical server identities, so resource reads, tool traces, Right Surface rendering, and reload recovery use one stable identity. +- Fixed Skills watcher changes being dropped inside the throttle window by notifying on the leading edge and coalescing a trailing update. +- Fixed stale session-switch, initial-navigation, and Plugin Right Surface state, and made uninstalled plugins retain history without restarting runtimes or replaying tools. +- Fixed canonical history persistence merging already-materialized snapshots a second time, which duplicated reasoning deltas, dropped repeated fragments, and regressed large Codex history imports; added bounded waits for transient SQLite lock contention. ### Improvements and Refactoring +- Consolidated Plugin catalog, installed/enabled state, package validation, and installation transactions in the App Server current owner; Renderer access now goes through the typed JSON-RPC gateway. +- Improved MCP resource provenance, canonical Thread/Turn/Item projection, and Agent Workspace Right Surface hosting while removing production dependence on the renderer legacy registry and Plugin worker. + ### Testing and Quality -- Added projection library coverage and a fork-compaction JSON-RPC integration regression for the metadata boundary and replacement/tail recovery after restart. +- Added Rust, Vitest, contract, and real Electron Gate B coverage for Plugin v2 App Center, typed gateway, mention/activation, MCP resources, Right Surface, history restoration, and uninstall semantics. +- Added regression guards for Electron packaged assets, embedded Browser HTML, GUI smoke, Skills watcher behavior, and the tool lifecycle. +- Added canonical reasoning linear-persistence and 1,200-command history-import performance regressions covering repeated deltas, final snapshot replacement, and background progress completion. ### Documentation +- Added Plugin v2 product contracts, installation model, command boundaries, App Center/Claw surfaces, migration cleanup, verification plans, and the corresponding current architecture diagrams. + ### Other -- Bumped the root app, CLI npm package, Rust workspace, and lockfile versions to `1.120.1`. +- Bumped the root app, CLI npm package, Rust workspace, and lockfile versions to `1.121.0`. -**Full changes**: `v1.120.0` -> `v1.120.1` +**Full changes**: `v1.120.1` -> `v1.121.0` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2e4cd57ee..cbbd1a16a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,21 +1,35 @@ -## Lime v1.120.1 +## Lime v1.121.0 ### 新功能 +- 推出 Plugin v2 current 主链:App Center 可从 bundled、仓库、个人与本地目录 catalog 发现 Codex-compatible Plugin,完成详情查看、安装确认、启停与卸载。 +- 已安装 Plugin 通过不可变 activation snapshot 进入当前 Agent runtime,支持 Claw 结构化 `plugin://` mention、Skill/MCP 注入、MCP App Right Surface 与历史恢复。 +- 新增受控的 Electron WebContentsView HTML 宿主与 bundled Browser Plugin 资产,并将 Plugin 资产纳入 Forge 打包链路。 + ### 修复 -- 修复普通运行时 Thread Item 的内部 provenance metadata 穿过 v2 投影边界,避免 `contextCompaction` 等公开 item 泄漏 `sourceEventType` 等私有字段;历史导入项仍保留受控的 typed metadata。 +- 修复 Plugin MCP provider namespace 被误当作 canonical server identity 的问题,使资源读取、tool trace、Right Surface 与 reload 恢复使用同一稳定 identity。 +- 修复 Skills watcher 在 throttle 窗口内丢失变更,改为首次立即通知并合并尾沿更新。 +- 修复会话切换、初始导航与 Plugin Right Surface 的过期状态,并确保 Plugin 卸载后只保留历史证据,不重启 runtime 或重放 tool。 +- 修复 canonical history 持久化对已物化快照二次 merge 导致的 reasoning delta 重复、重复片段丢失与 Codex 大历史导入退化,并为 SQLite 短暂锁竞争增加有界等待。 ### 优化与重构 +- 将 Plugin catalog、installed/enabled state、包校验与安装事务收口到 App Server current owner,Renderer 统一通过 typed JSON-RPC gateway 访问。 +- 完善 MCP resource provenance、canonical Thread/Turn/Item 投影与 Agent Workspace 右侧承载,移除对 renderer 旧 registry 和 Plugin worker 的生产依赖。 + ### 测试与质量 -- 补充 projection library 单测与 fork-compaction JSON-RPC 集成回归,确认 metadata 边界和重启后的替换/尾部恢复行为。 +- 新增 Plugin v2 App Center、typed gateway、mention/activation、MCP resource、Right Surface、历史恢复与卸载语义的 Rust、Vitest、contract 和真实 Electron Gate B 覆盖。 +- 补充 Electron 打包资产、内嵌 Browser HTML、GUI smoke、Skills watcher 和 tool lifecycle 的回归守卫。 +- 新增 canonical reasoning 线性持久化与 1,200 条命令历史导入性能回归,覆盖重复 delta、final 快照替换和后台进度完成。 ### 文档 +- 新增 Plugin v2 产品合同、安装模型、命令边界、App Center/Claw Surface、迁移清理与验收计划,并同步 current 架构图。 + ### 其他 -- 将根应用、CLI npm 包、Rust workspace 与锁文件版本统一提升到 `1.120.1`。 +- 将根应用、CLI npm 包、Rust workspace 与锁文件版本统一提升到 `1.121.0`。 -**完整变更**: `v1.120.0` -> `v1.120.1` +**完整变更**: `v1.120.1` -> `v1.121.0` diff --git a/assets/plugins/openai-bundled/.agents/plugins/marketplace.json b/assets/plugins/openai-bundled/.agents/plugins/marketplace.json new file mode 100644 index 000000000..48a0c32da --- /dev/null +++ b/assets/plugins/openai-bundled/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "openai-bundled", + "interface": { + "displayName": "Lime Bundled" + }, + "plugins": [ + { + "name": "browser", + "source": { + "source": "local", + "path": "./plugins/browser" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_USE" + }, + "category": "Productivity" + } + ] +} diff --git a/assets/plugins/openai-bundled/plugins/browser/skills/control-browser/SKILL.md b/assets/plugins/openai-bundled/plugins/browser/skills/control-browser/SKILL.md new file mode 100644 index 000000000..e4015f5ba --- /dev/null +++ b/assets/plugins/openai-bundled/plugins/browser/skills/control-browser/SKILL.md @@ -0,0 +1,13 @@ +--- +name: control-browser +description: Research and inspect pages through Lime's managed browser session. +--- + +# Managed Browser + +Use the existing Lime browser session for page inspection and navigation tasks. + +- Prefer the current Lime browser session and its Right Surface projection. +- Keep browser intent and the resulting tool item associated with the current thread. +- Ask for confirmation before external writes, account changes, or irreversible actions. +- Do not claim that a page was opened or changed unless the browser tool returns a terminal result. diff --git a/electron/appServerHost.ts b/electron/appServerHost.ts index 4b4c1026b..ff119e9f5 100644 --- a/electron/appServerHost.ts +++ b/electron/appServerHost.ts @@ -708,6 +708,10 @@ async function resolveAppServerSidecarEnv( // AppDataRoot 由 Host 显式解析后下发;App Server 的叶子 writer 不再自行猜平台根。 APP_SERVER_APP_DATA_DIR: resolveAppServerAppDataRoot(), }; + const bundledMarketplacePath = resolveBundledPluginMarketplacePath(); + if (bundledMarketplacePath) { + env.LIME_BUNDLED_PLUGIN_MARKETPLACE = bundledMarketplacePath; + } const currentNoProxy = APP_SERVER_NO_PROXY_ENV_KEYS.map( (key) => process.env[key], ).find((value) => Boolean(value?.trim())); @@ -731,6 +735,31 @@ async function resolveAppServerSidecarEnv( return Object.keys(env).length > 0 ? env : undefined; } +function resolveBundledPluginMarketplacePath(): string | undefined { + const resourceRoots = [ + process.resourcesPath, + path.resolve(app.getAppPath(), "dist-electron"), + ]; + for (const resourceRoot of resourceRoots) { + const marketplacePath = path.join( + resourceRoot, + "plugins", + "openai-bundled", + ".agents", + "plugins", + "marketplace.json", + ); + try { + if (readFileSync(marketplacePath, "utf8")) { + return marketplacePath; + } + } catch { + // 开发态或旧包可能没有 bundled marketplace,App Server 继续发现其他来源。 + } + } + return undefined; +} + function resolveAppServerRuntimeLibraryEnv( binaryPath: string, ): NodeJS.ProcessEnv { diff --git a/electron/embeddedBrowserHost.test.ts b/electron/embeddedBrowserHost.test.ts index 9c7e19e2c..4c36d8c54 100644 --- a/electron/embeddedBrowserHost.test.ts +++ b/electron/embeddedBrowserHost.test.ts @@ -166,6 +166,10 @@ vi.mock("./electronRuntime", () => ({ function createWindow() { let destroyed = false; + const windowEventHandlers = new Map< + string, + Array<(...args: unknown[]) => void> + >(); const windowWebContentsEventHandlers = new Map< string, Array<(...args: unknown[]) => void> @@ -184,9 +188,24 @@ function createWindow() { handler(...args); } }, + emitWindowEventForTest: (eventName: string, ...args: unknown[]) => { + for (const handler of windowEventHandlers.get(eventName) || []) { + handler(...args); + } + }, isDestroyed: () => destroyed, - off: vi.fn(), - on: vi.fn(), + off: vi.fn((eventName: string, handler: (...args: unknown[]) => void) => { + const handlers = windowEventHandlers.get(eventName) || []; + windowEventHandlers.set( + eventName, + handlers.filter((item) => item !== handler), + ); + }), + on: vi.fn((eventName: string, handler: (...args: unknown[]) => void) => { + const handlers = windowEventHandlers.get(eventName) || []; + handlers.push(handler); + windowEventHandlers.set(eventName, handlers); + }), webContents: { off: vi.fn((eventName: string, handler: (...args: unknown[]) => void) => { const handlers = windowWebContentsEventHandlers.get(eventName) || []; @@ -329,7 +348,88 @@ describe("ElectronEmbeddedBrowserHost", () => { ).toBe(true); }); - it("窗口关闭后销毁内嵌浏览器不访问已销毁 BrowserWindow", async () => { + it("通过受控命令加载 MCP App HTML,并保持 ui source URI", async () => { + resetMocks(); + const host = new ElectronEmbeddedBrowserHost(); + const window = createWindow(); + + await host.invoke(window as never, "embedded_browser_view_mount", { + viewId: "mcp-app-1", + bounds: { x: 0, y: 0, width: 480, height: 640 }, + }); + await expect( + host.invoke(window as never, "embedded_browser_view_load_html", { + viewId: "mcp-app-1", + html: "Plugin report", + source: "mcpApp", + sourceUri: "ui://plugin/report.html", + }), + ).resolves.toMatchObject({ + viewId: "mcp-app-1", + url: "ui://plugin/report.html", + }); + + expect(loadUrlMock).toHaveBeenLastCalledWith( + expect.stringMatching(/^data:text\/html;charset=utf-8;base64,/), + ); + }); + + it("受控 HTML 命令拒绝普通网页来源,普通导航仍拒绝 ui/data", async () => { + resetMocks(); + const host = new ElectronEmbeddedBrowserHost(); + const window = createWindow(); + + await expect( + host.invoke(window as never, "embedded_browser_view_load_html", { + viewId: "mcp-app-1", + html: "

bad

", + source: "browser", + sourceUri: "ui://plugin/report.html", + }), + ).rejects.toThrow("source 必须是 mcpApp"); + await expect( + host.invoke(window as never, "embedded_browser_view_navigate", { + viewId: "mcp-app-1", + url: "ui://plugin/report.html", + }), + ).rejects.toThrow("只支持 http/https"); + await expect( + host.invoke(window as never, "embedded_browser_view_navigate", { + viewId: "mcp-app-1", + url: "data:text/html,unsafe", + }), + ).rejects.toThrow("只支持 http/https"); + }); + + it("MCP App 加载后拒绝页面内 http 导航和新窗口", async () => { + resetMocks(); + const host = new ElectronEmbeddedBrowserHost(); + const window = createWindow(); + await host.invoke(window as never, "embedded_browser_view_mount", { + viewId: "mcp-app-1", + }); + await host.invoke(window as never, "embedded_browser_view_load_html", { + viewId: "mcp-app-1", + html: 'blocked', + source: "mcpApp", + sourceUri: "ui://plugin/report.html", + }); + + const preventDefault = vi.fn(); + emitWebContentsEvent( + "will-navigate", + { preventDefault }, + "https://example.com/", + ); + expect(preventDefault).toHaveBeenCalledOnce(); + const openHandler = setWindowOpenHandlerMock.mock.calls.at(-1)?.[0]; + expect(openHandler?.({ url: "https://example.com/" })).toEqual({ + action: "deny", + }); + expect(loadUrlMock).toHaveBeenCalledTimes(1); + }); + + it("host dispose 不访问已销毁 BrowserWindow", async () => { resetMocks(); const emitted: Array<{ event: string; payload?: unknown }> = []; const host = new ElectronEmbeddedBrowserHost((event, payload) => { @@ -350,11 +450,80 @@ describe("ElectronEmbeddedBrowserHost", () => { expect(closeMock).toHaveBeenCalled(); expect(emitted).toContainEqual({ event: "embedded-browser-view-destroyed", - payload: { viewId: "browser-1" }, + payload: { + viewId: "browser-1", + reason: "host-dispose", + requestedLeaseId: null, + remainingLeaseCount: 0, + }, }); }); - it("主窗口 renderer 刷新时销毁内嵌浏览器原生视图", async () => { + it("窗口 closed 事件应记录 window-closed 销毁原因", async () => { + resetMocks(); + const emitted: Array<{ event: string; payload?: unknown }> = []; + const host = new ElectronEmbeddedBrowserHost((event, payload) => { + emitted.push({ event, payload }); + }); + const window = createWindow(); + + await host.invoke(window as never, "embedded_browser_view_mount", { + viewId: "browser-1", + leaseId: "lease-1", + }); + window.destroyForTest(); + window.emitWindowEventForTest("closed"); + + expect(closeMock).toHaveBeenCalledOnce(); + expect(emitted).toContainEqual({ + event: "embedded-browser-view-destroyed", + payload: { + viewId: "browser-1", + reason: "window-closed", + requestedLeaseId: null, + remainingLeaseCount: 1, + }, + }); + expect(() => host.dispose()).not.toThrow(); + expect(closeMock).toHaveBeenCalledOnce(); + }); + + it("陈旧租约释放不得销毁同 viewId 的当前原生视图", async () => { + resetMocks(); + const host = new ElectronEmbeddedBrowserHost(); + const window = createWindow(); + + await host.invoke(window as never, "embedded_browser_view_mount", { + viewId: "plugin-surface-1", + leaseId: "lease-old", + bounds: { x: 10, y: 20, width: 300, height: 200 }, + }); + await host.invoke(window as never, "embedded_browser_view_mount", { + viewId: "plugin-surface-1", + leaseId: "lease-current", + bounds: { x: 10, y: 20, width: 300, height: 200 }, + }); + + removeChildViewMock.mockClear(); + closeMock.mockClear(); + await host.invoke(window as never, "embedded_browser_view_destroy", { + viewId: "plugin-surface-1", + leaseId: "lease-old", + }); + + expect(removeChildViewMock).not.toHaveBeenCalled(); + expect(closeMock).not.toHaveBeenCalled(); + + await host.invoke(window as never, "embedded_browser_view_destroy", { + viewId: "plugin-surface-1", + leaseId: "lease-current", + }); + + expect(removeChildViewMock).toHaveBeenCalledTimes(1); + expect(closeMock).toHaveBeenCalledTimes(1); + }); + + it("只在主窗口开始完整导航时销毁内嵌浏览器原生视图", async () => { resetMocks(); const emitted: Array<{ event: string; payload?: unknown }> = []; const host = new ElectronEmbeddedBrowserHost((event, payload) => { @@ -371,12 +540,42 @@ describe("ElectronEmbeddedBrowserHost", () => { removeChildViewMock.mockClear(); closeMock.mockClear(); window.emitWebContentsEventForTest("did-start-loading"); + window.emitWebContentsEventForTest( + "did-start-navigation", + {}, + "http://127.0.0.1:1420/#settings", + true, + true, + ); + window.emitWebContentsEventForTest( + "did-start-navigation", + {}, + "https://example.com/frame", + false, + false, + ); + + expect(removeChildViewMock).not.toHaveBeenCalled(); + expect(closeMock).not.toHaveBeenCalled(); + + window.emitWebContentsEventForTest( + "did-start-navigation", + {}, + "http://127.0.0.1:1420/", + false, + true, + ); expect(removeChildViewMock).toHaveBeenCalled(); expect(closeMock).toHaveBeenCalled(); expect(emitted).toContainEqual({ event: "embedded-browser-view-destroyed", - payload: { viewId: "browser-1" }, + payload: { + viewId: "browser-1", + reason: "renderer-navigation", + requestedLeaseId: null, + remainingLeaseCount: 0, + }, }); }); diff --git a/electron/embeddedBrowserHost.ts b/electron/embeddedBrowserHost.ts index c856db214..6520a808e 100644 --- a/electron/embeddedBrowserHost.ts +++ b/electron/embeddedBrowserHost.ts @@ -7,6 +7,10 @@ import { import { installEmbeddedBrowserContextMenu } from "./embeddedBrowserContextMenu"; import { installEmbeddedBrowserDownloadHandling } from "./embeddedBrowserDownloads"; import { installEmbeddedBrowserPermissionHandling } from "./embeddedBrowserPermissions"; +import { + buildEmbeddedBrowserHtmlDataUrl, + readEmbeddedBrowserHtmlPayload, +} from "./embeddedBrowserHtml"; import type { Session as ElectronSession, WebContents as ElectronWebContents, @@ -14,11 +18,17 @@ import type { type HostArgs = Record | null | undefined; type HostEventEmitter = (event: string, payload?: unknown) => void; +type EmbeddedBrowserDestroyReason = + | "renderer-cleanup" + | "renderer-navigation" + | "window-closed" + | "host-dispose"; export const EMBEDDED_BROWSER_COMMANDS = [ "embedded_browser_view_mount", "embedded_browser_view_set_bounds", "embedded_browser_view_navigate", + "embedded_browser_view_load_html", "embedded_browser_view_reload", "embedded_browser_view_stop", "embedded_browser_view_find_in_page", @@ -63,8 +73,15 @@ interface EmbeddedBrowserEntry { view: WebContentsView; window: BrowserWindow; closeListener: () => void; - rendererLoadListener: () => void; + rendererNavigationListener: ( + event: unknown, + url: string, + isInPlace: boolean, + isMainFrame: boolean, + ) => void; + leaseIds: Set; pendingUrl?: string; + sourceUri?: string; navigationToken: number; faviconUrl: string | null; loadProgress: number; @@ -96,7 +113,7 @@ export class ElectronEmbeddedBrowserHost { args?: HostArgs, ): Promise { if (command === "embedded_browser_view_destroy") { - return this.#destroy(args); + return this.#destroy(args, "renderer-cleanup"); } if (!window || window.isDestroyed()) { @@ -110,6 +127,8 @@ export class ElectronEmbeddedBrowserHost { return this.#setBounds(window, args); case "embedded_browser_view_navigate": return await this.#navigate(window, args); + case "embedded_browser_view_load_html": + return await this.#loadHtml(window, args); case "embedded_browser_view_reload": return this.#reload(window, args); case "embedded_browser_view_stop": @@ -131,7 +150,7 @@ export class ElectronEmbeddedBrowserHost { dispose(): void { for (const viewId of [...this.#entries.keys()]) { - this.#destroy({ viewId }); + this.#destroy({ viewId }, "host-dispose"); } } @@ -144,6 +163,10 @@ export class ElectronEmbeddedBrowserHost { const bounds = readOptionalBounds(args); const visible = readOptionalBoolean(args, "visible") ?? true; const entry = this.#ensureEntry(window, viewId); + const leaseId = readString(args, "leaseId"); + if (leaseId) { + entry.leaseIds.add(leaseId); + } if (bounds) { applyBounds(entry.view, bounds, visible); @@ -175,6 +198,21 @@ export class ElectronEmbeddedBrowserHost { return this.#emitState(entry); } + async #loadHtml( + window: BrowserWindow, + args?: HostArgs, + ): Promise { + const entry = this.#ensureEntry(window, readViewId(args)); + const payload = readEmbeddedBrowserHtmlPayload(args); + entry.sourceUri = payload.sourceUri; + this.#startNavigation( + entry, + payload.sourceUri, + buildEmbeddedBrowserHtmlDataUrl(payload), + ); + return this.#emitState(entry); + } + #reload(window: BrowserWindow, args?: HostArgs): EmbeddedBrowserViewState { const entry = this.#ensureEntry(window, readViewId(args)); entry.loadProgress = 0.1; @@ -249,17 +287,31 @@ export class ElectronEmbeddedBrowserHost { return this.#emitState(entry); } - #destroy(args?: HostArgs): Record { + #destroy( + args: HostArgs, + reason: EmbeddedBrowserDestroyReason, + ): Record { const viewId = readViewId(args); const entry = this.#entries.get(viewId); if (!entry) { return {}; } + const leaseId = readString(args, "leaseId"); + if (leaseId) { + if (!entry.leaseIds.delete(leaseId) || entry.leaseIds.size > 0) { + return {}; + } + } this.#entries.delete(viewId); detachEntryFromWindow(entry); closeEntryView(entry); - this.#emit("embedded-browser-view-destroyed", { viewId }); + this.#emit("embedded-browser-view-destroyed", { + viewId, + reason, + requestedLeaseId: leaseId, + remainingLeaseCount: entry.leaseIds.size, + }); return {}; } @@ -269,8 +321,10 @@ export class ElectronEmbeddedBrowserHost { if (existing.window !== window) { detachEntryFromWindow(existing); existing.window = window; - existing.closeListener = () => this.#destroy({ viewId }); - existing.rendererLoadListener = () => this.#destroy({ viewId }); + existing.closeListener = () => + this.#destroy({ viewId }, "window-closed"); + existing.rendererNavigationListener = + this.#createRendererNavigationListener(viewId); attachEntryToWindow(existing, window); return existing; } @@ -297,8 +351,10 @@ export class ElectronEmbeddedBrowserHost { viewId, view, window, - closeListener: () => this.#destroy({ viewId }), - rendererLoadListener: () => this.#destroy({ viewId }), + closeListener: () => this.#destroy({ viewId }, "window-closed"), + rendererNavigationListener: + this.#createRendererNavigationListener(viewId), + leaseIds: new Set(), navigationToken: 0, faviconUrl: null, loadProgress: 1, @@ -306,6 +362,9 @@ export class ElectronEmbeddedBrowserHost { findRequestId: null, }; view.webContents.setWindowOpenHandler(({ url }) => { + if (entry.sourceUri) { + return { action: "deny" }; + } const normalizedUrl = normalizeHttpUrl(url); if (normalizedUrl) { this.#startNavigation(entry, normalizedUrl); @@ -313,6 +372,11 @@ export class ElectronEmbeddedBrowserHost { } return { action: "deny" }; }); + view.webContents.on("will-navigate", (event, url) => { + if (entry.sourceUri && normalizeHttpUrl(url)) { + event.preventDefault(); + } + }); installEmbeddedBrowserContextMenu({ view, window, @@ -383,6 +447,16 @@ export class ElectronEmbeddedBrowserHost { return entry; } + #createRendererNavigationListener( + viewId: string, + ): EmbeddedBrowserEntry["rendererNavigationListener"] { + return (_event, _url, isInPlace, isMainFrame) => { + if (isMainFrame && !isInPlace) { + this.#destroy({ viewId }, "renderer-navigation"); + } + }; + } + #installSessionHandlers(embeddedSession: ElectronSession): void { if (this.#sessionHandlersInstalled) { return; @@ -416,7 +490,14 @@ export class ElectronEmbeddedBrowserHost { return null; } - #startNavigation(entry: EmbeddedBrowserEntry, url: string): void { + #startNavigation( + entry: EmbeddedBrowserEntry, + url: string, + loadUrl = url, + ): void { + if (loadUrl === url) { + entry.sourceUri = undefined; + } entry.pendingUrl = url; entry.loadProgress = 0.1; const navigationToken = entry.navigationToken + 1; @@ -424,7 +505,7 @@ export class ElectronEmbeddedBrowserHost { let loadPromise: Promise; try { - loadPromise = entry.view.webContents.loadURL(url); + loadPromise = entry.view.webContents.loadURL(loadUrl); } catch (error) { this.#clearPendingNavigation(entry); this.#emitLoadFailed( @@ -535,7 +616,7 @@ function readState(entry: EmbeddedBrowserEntry): EmbeddedBrowserViewState { const webContents = entry.view.webContents; return { viewId: entry.viewId, - url: entry.pendingUrl || webContents.getURL(), + url: entry.pendingUrl || entry.sourceUri || webContents.getURL(), title: webContents.getTitle(), faviconUrl: entry.faviconUrl, canGoBack: webContents.navigationHistory.canGoBack(), @@ -658,7 +739,10 @@ function attachEntryToWindow( window: BrowserWindow, ): void { window.on("closed", entry.closeListener); - window.webContents.on("did-start-loading", entry.rendererLoadListener); + window.webContents.on( + "did-start-navigation", + entry.rendererNavigationListener, + ); window.contentView.addChildView(entry.view); } @@ -667,8 +751,8 @@ function detachEntryFromWindow(entry: EmbeddedBrowserEntry): void { if (!entry.window.isDestroyed()) { entry.window.off("closed", entry.closeListener); entry.window.webContents.off( - "did-start-loading", - entry.rendererLoadListener, + "did-start-navigation", + entry.rendererNavigationListener, ); entry.window.contentView.removeChildView(entry.view); } diff --git a/electron/embeddedBrowserHtml.test.ts b/electron/embeddedBrowserHtml.test.ts new file mode 100644 index 000000000..0bc262a53 --- /dev/null +++ b/electron/embeddedBrowserHtml.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + buildEmbeddedBrowserHtmlDataUrl, + EMBEDDED_BROWSER_HTML_MAX_BYTES, + readEmbeddedBrowserHtmlPayload, +} from "./embeddedBrowserHtml"; + +describe("embeddedBrowserHtml", () => { + it("只接受受控 MCP App HTML 载荷", () => { + expect( + readEmbeddedBrowserHtmlPayload({ + html: "Plugin", + source: "mcpApp", + sourceUri: "ui://plugin/report.html", + }), + ).toEqual({ + csp: { + baseUriDomains: [], + connectDomains: [], + frameDomains: [], + resourceDomains: [], + }, + html: "Plugin", + source: "mcpApp", + sourceUri: "ui://plugin/report.html", + }); + }); + + it("拒绝非 MCP App 来源、非 ui URI 与超限 HTML", () => { + expect(() => + readEmbeddedBrowserHtmlPayload({ + html: "

bad source

", + source: "browser", + sourceUri: "ui://plugin/report.html", + }), + ).toThrow("source 必须是 mcpApp"); + expect(() => + readEmbeddedBrowserHtmlPayload({ + html: "

bad uri

", + source: "mcpApp", + sourceUri: "https://example.com/report.html", + }), + ).toThrow("sourceUri 必须使用 ui:// 协议"); + expect(() => + readEmbeddedBrowserHtmlPayload({ + html: "a".repeat(EMBEDDED_BROWSER_HTML_MAX_BYTES + 1), + source: "mcpApp", + sourceUri: "ui://plugin/report.html", + }), + ).toThrow("HTML 超过"); + }); + + it("将 HTML 与受控 CSP 编码为不暴露原文的数据 URL", () => { + const dataUrl = buildEmbeddedBrowserHtmlDataUrl({ + csp: { + baseUriDomains: [], + connectDomains: ["https://api.example.com"], + frameDomains: [], + resourceDomains: ["https://cdn.example.com"], + }, + html: "

报告

", + }); + expect(dataUrl).toMatch(/^data:text\/html;charset=utf-8;base64,/); + expect(dataUrl).not.toContain("报告"); + const encoded = dataUrl.slice(dataUrl.indexOf(",") + 1); + const decoded = Buffer.from(encoded, "base64").toString("utf8"); + expect(decoded).toContain('http-equiv="Content-Security-Policy"'); + expect(decoded).toContain("connect-src https://api.example.com"); + expect(decoded).toContain("script-src 'unsafe-inline' https://cdn.example.com"); + expect(decoded).toContain("frame-src 'none'"); + }); + + it("拒绝 CSP 指令注入、路径与不受支持协议", () => { + for (const connectDomain of [ + "https://api.example.com; script-src *", + "https://api.example.com/path", + "file:///tmp/app", + ]) { + expect(() => + readEmbeddedBrowserHtmlPayload({ + csp: { connectDomains: [connectDomain] }, + html: "
app
", + source: "mcpApp", + sourceUri: "ui://plugin/report.html", + }), + ).toThrow("包含无效 origin"); + } + }); +}); diff --git a/electron/embeddedBrowserHtml.ts b/electron/embeddedBrowserHtml.ts new file mode 100644 index 000000000..5c2dba3e7 --- /dev/null +++ b/electron/embeddedBrowserHtml.ts @@ -0,0 +1,197 @@ +type HostArgs = Record | null | undefined; + +export const EMBEDDED_BROWSER_HTML_MAX_BYTES = 1024 * 1024; + +export interface EmbeddedBrowserHtmlPayload { + csp: EmbeddedBrowserMcpAppCsp; + html: string; + source: "mcpApp"; + sourceUri: string; +} + +export interface EmbeddedBrowserMcpAppCsp { + baseUriDomains: string[]; + connectDomains: string[]; + frameDomains: string[]; + resourceDomains: string[]; +} + +export function readEmbeddedBrowserHtmlPayload( + args: HostArgs, +): EmbeddedBrowserHtmlPayload { + const record = readRecord(args); + if (record?.source !== "mcpApp") { + throw new Error("embedded browser HTML source 必须是 mcpApp。"); + } + + const sourceUri = readRequiredString(record, "sourceUri"); + if (!isMcpAppResourceUri(sourceUri)) { + throw new Error("MCP App sourceUri 必须使用 ui:// 协议。"); + } + + const html = readRequiredString(record, "html", false); + const byteLength = Buffer.byteLength(html, "utf8"); + if (byteLength > EMBEDDED_BROWSER_HTML_MAX_BYTES) { + throw new Error( + `MCP App HTML 超过 ${EMBEDDED_BROWSER_HTML_MAX_BYTES} 字节上限。`, + ); + } + + return { + csp: readMcpAppCsp(record?.csp), + html, + source: "mcpApp", + sourceUri, + }; +} + +export function buildEmbeddedBrowserHtmlDataUrl( + payload: Pick, +): string { + const html = injectMcpAppContentSecurityPolicy(payload.html, payload.csp); + return `data:text/html;charset=utf-8;base64,${Buffer.from(html, "utf8").toString("base64")}`; +} + +function readMcpAppCsp(value: unknown): EmbeddedBrowserMcpAppCsp { + const record = readRecord(value); + return { + baseUriDomains: readDomainList(record?.baseUriDomains, "baseUriDomains", [ + "http:", + "https:", + ]), + connectDomains: readDomainList(record?.connectDomains, "connectDomains", [ + "http:", + "https:", + "ws:", + "wss:", + ]), + frameDomains: readDomainList(record?.frameDomains, "frameDomains", [ + "http:", + "https:", + ]), + resourceDomains: readDomainList( + record?.resourceDomains, + "resourceDomains", + ["http:", "https:"], + ), + }; +} + +function readDomainList( + value: unknown, + field: string, + protocols: readonly string[], +): string[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value) || value.length > 32) { + throw new Error(`MCP App CSP ${field} 必须是最多 32 项的数组。`); + } + return Array.from( + new Set( + value.map((entry) => normalizeCspOrigin(entry, field, protocols)), + ), + ); +} + +function normalizeCspOrigin( + value: unknown, + field: string, + protocols: readonly string[], +): string { + if (typeof value !== "string" || value.length > 2048) { + throw new Error(`MCP App CSP ${field} 包含无效 origin。`); + } + const trimmed = value.trim(); + const wildcard = /^(https?):\/\/\*\.(.+)$/i.exec(trimmed); + const parseTarget = wildcard ? `${wildcard[1]}://${wildcard[2]}` : trimmed; + try { + const url = new URL(parseTarget); + if ( + !protocols.includes(url.protocol) || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash || + !url.hostname + ) { + throw new Error("invalid origin"); + } + return wildcard + ? `${url.protocol}//* .${url.host}`.replace("* .", "*.") + : url.origin; + } catch { + throw new Error(`MCP App CSP ${field} 包含无效 origin。`); + } +} + +function injectMcpAppContentSecurityPolicy( + html: string, + csp: EmbeddedBrowserMcpAppCsp, +): string { + const resourceSources = csp.resourceDomains.join(" "); + const policy = [ + "default-src 'none'", + `script-src 'unsafe-inline'${appendSources(resourceSources)}`, + `style-src 'unsafe-inline'${appendSources(resourceSources)}`, + `img-src data: blob:${appendSources(resourceSources)}`, + `font-src data:${appendSources(resourceSources)}`, + `media-src data: blob:${appendSources(resourceSources)}`, + `connect-src ${sourcesOrNone(csp.connectDomains)}`, + `frame-src ${sourcesOrNone(csp.frameDomains)}`, + `base-uri ${sourcesOrNone(csp.baseUriDomains)}`, + "object-src 'none'", + "form-action 'none'", + ].join("; "); + const tag = ``; + const headMatch = /]*)?>/i.exec(html); + if (headMatch?.index !== undefined) { + const offset = headMatch.index + headMatch[0].length; + return `${html.slice(0, offset)}${tag}${html.slice(offset)}`; + } + return `${tag}${html}`; +} + +function appendSources(value: string): string { + return value ? ` ${value}` : ""; +} + +function sourcesOrNone(values: readonly string[]): string { + return values.length > 0 ? values.join(" ") : "'none'"; +} + +function escapeHtmlAttribute(value: string): string { + return value.replaceAll("&", "&").replaceAll('"', """); +} + +function isMcpAppResourceUri(value: string): boolean { + try { + return new URL(value).protocol === "ui:"; + } catch { + return false; + } +} + +function readRequiredString( + record: Record | null, + key: string, + trim = true, +): string { + const value = record?.[key]; + if (typeof value !== "string") { + throw new Error(`embedded browser ${key} 不能为空。`); + } + const normalized = trim ? value.trim() : value; + if (!normalized.trim()) { + throw new Error(`embedded browser ${key} 不能为空。`); + } + return normalized; +} + +function readRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} diff --git a/electron/ipcChannels.test.ts b/electron/ipcChannels.test.ts index 17924ef6c..95e84d23e 100644 --- a/electron/ipcChannels.test.ts +++ b/electron/ipcChannels.test.ts @@ -135,6 +135,7 @@ describe("electron/ipcChannels", () => { true, ); expect(isElectronHostCommand("embedded_browser_view_navigate")).toBe(true); + expect(isElectronHostCommand("embedded_browser_view_load_html")).toBe(true); expect(isElectronHostCommand("embedded_browser_view_reload")).toBe(true); expect(isElectronHostCommand("embedded_browser_view_stop")).toBe(true); expect(isElectronHostCommand("embedded_browser_view_find_in_page")).toBe( diff --git a/electron/ipcChannels.ts b/electron/ipcChannels.ts index a32fe3fea..0dd44561a 100644 --- a/electron/ipcChannels.ts +++ b/electron/ipcChannels.ts @@ -18,6 +18,7 @@ export const ELECTRON_HOST_COMMANDS = [ "embedded_browser_view_mount", "embedded_browser_view_set_bounds", "embedded_browser_view_navigate", + "embedded_browser_view_load_html", "embedded_browser_view_reload", "embedded_browser_view_stop", "embedded_browser_view_find_in_page", diff --git a/forge.config.mjs b/forge.config.mjs index eba36121f..85feabfa4 100644 --- a/forge.config.mjs +++ b/forge.config.mjs @@ -240,6 +240,7 @@ export default { : "lime-rs/icons/icon.icns", extraResource: [ "dist-electron/desktop-assets", + "dist-electron/plugins", "dist-electron/app-server.release.json", "dist-electron/app-server", ], diff --git a/internal/aiprompts/architecture.md b/internal/aiprompts/architecture.md index 29935fc46..0b1af16d5 100644 --- a/internal/aiprompts/architecture.md +++ b/internal/aiprompts/architecture.md @@ -891,6 +891,47 @@ Architecture impact: major; this changes the Renderer live/read/resume Item stat Plugin UI/worker 只通过 typed client 和 App Server method 进入 runtime;不得复制 turn start、cancel、tool dispatch 或 evidence 链。Skill 是产品与领域工作流单元,MCP 是标准化 tool/resource/prompt 接入;二者均由 App Server 和各自 runtime owner 注入当前 turn,而不是由 Renderer 直接执行。 +Plugin v2 的 catalog、installed state、enabled state、package identity 和安装事务由 App Server current domain 唯一持有。Renderer App Center 只能通过 `src/lib/api/pluginCatalog.ts` 调用 typed `plugin/list`、`plugin/read`、`plugin/install`、`plugin/uninstall`、`plugin/installed` 与 `plugin/enabled/set`;Electron 只转发 `app_server_handle_json_lines` 和提供原生目录选择,不扫描 `.codex-plugin/plugin.json`、不合并 marketplace,也不保存第二份 installed state。 + +```text +PluginCatalogPage + -> Renderer pluginCatalog gateway + -> typed AppServerClient + -> Electron preload / app_server_handle_json_lines + -> App Server Plugin processor + -> RuntimeCore PluginDataSource + -> plugin_catalog + catalog discovery + package validation + sha256 identity + staging + atomic installed record + -> /plugins/v2/{packages,installed,staging,marketplaces} +``` + +包合同只接受 Codex-compatible `.codex-plugin/plugin.json`;旧 `schemaVersion`、`contributions.runtime/workbench`、绝对或父级资源路径、symlink、超预算 package 必须 fail closed。安装 identity 至少包含 `pluginId + marketplaceId + version + contentDigest`。同 identity 同 digest 的重复安装幂等;同 identity 不同 digest 拒绝;更新必须先提交新版本和 installed record,失败时保留上一可用版本。`sourceUri` 只用于受控本地 catalog/install 响应,不进入 Thread 历史、日志或跨设备 metadata。 + +installed+enabled Plugin 由同一 App Server store 生成 `plugin-activation/v2` 与 `plugin-runtime-capabilities/v2`。activation snapshot 冻结 `pluginId + version + contentDigest + marketplaceId + packageSourceUri`,其 Skills 进入现有 skill snapshot;Renderer 不得构造或注入 activation。Claw 的已安装候选只从 typed `plugin/installed` 投影,选择后把显示文本 `@DisplayName` 与结构化 `{ type: "mention", name, path: "plugin://" }` 同时送入 current turn contract。App Server 使用同一回合 activation snapshot 校验 mention:无显式 mention 时保留全部 enabled Plugin 供描述发现;有 `plugin://` 时只把解析成功的 Plugin activation 装配到该回合,未知、禁用或 identity 漂移必须 fail closed,不能查询旧 renderer registry 或旧 installed state 兜底。 + +```text +App Server plugin v2 installed store + -> enabled activation snapshot + -> RuntimeCore session config / Skill snapshot + +Claw @ picker + -> Renderer pluginCatalog gateway -> plugin/installed + -> stable plugin:// UserInput::Mention + -> Electron IPC / app_server_handle_json_lines + -> App Server mention selection + -> selected activation -> Turn metadata / runtime context +``` + +Plugin MCP 声明由唯一 package manifest `.codex-plugin/plugin.json` 选择:可以使用 manifest 内联 object、package-relative 配置路径,或包根默认 `.mcp.json`;`.mcp.json` 只是 MCP server 配置,不是第二份 Plugin manifest。installed+enabled Plugin 生成带稳定 `plugin____` runtime identity 的 server spec,并进入既有 `McpThreadRuntime -> McpClientManager -> tool-runtime` lifecycle;disabled Plugin 不装配,非法 sibling 与越界 `cwd` fail closed。MCP App UI 从 canonical tool item 的 `resourceUri` 经 `mcpResource/read` 进入现有 Right Surface/WebContentsView,并以 canonical identity 在 Renderer reload 后恢复,不启动 Plugin worker 或私有 UI runtime。 + +当前仍未完成独立 Plugin Apps(非 MCP App)与 Hooks activation、Plugin identity 到完整 Item/tool trace 的全量历史投影、跨进程 cold restore,以及 Browser/file/structured-result 的 Plugin 专项 Gate B;这些缺口不得通过旧 renderer registry、plugin worker 或独立 UI runtime fallback 填补。 + +2026-08-05 的 macOS controlled fixture 已证明 `plugin/install -> enabled activation -> runtime MCP tool -> elicitation -> provider final -> canonical item -> MCP App Right Surface -> Renderer reload restore`,且 preload/IPC 与 `app_server_handle_json_lines` 可见、legacy MCP command / production mock fallback / Plugin worker hit 均为 0。证据位于 `.lime/qc/gui-evidence/plugin-v2-current-electron-fixture/`;该证据不扩张为 App Center 安装点击、Claw `@` picker 点击、卸载历史或 Windows release gate 已通过。 + +Architecture impact: major; this adds the Plugin v2 App Server domain, moves the production App Center and Claw mention source to typed JSON-RPC catalog/activation state, and preserves Electron as transport host. Architecture diagram updated: this section. Responsible developer confirmation: pending. + MCP server 的执行环境身份只来自 `McpServerConfig.environment_id`,由 `lime-mcp::McpEnvironmentRegistry` 在 transport 启动前解析。当前 registry 只注册 `local`;未知显式身份必须 fail closed,禁止把 `remote` 或其它配置值降级成本机 stdio/HTTP 执行,也禁止从 `cwd` 猜测环境。远程 executor/backend diff --git a/internal/exec-plans/README.md b/internal/exec-plans/README.md index 8cdf67ef7..919388954 100644 --- a/internal/exec-plans/README.md +++ b/internal/exec-plans/README.md @@ -36,6 +36,7 @@ - Lime v1.114.0 发布执行计划:`internal/exec-plans/release-v1.114.0-plan.md` - Lime v1.115.0 发布执行计划:`internal/exec-plans/release-v1.115.0-plan.md` - Lime v1.116.0 发布执行计划:`internal/exec-plans/release-v1.116.0-plan.md` +- Lime v1.121.0 发布执行计划:`internal/exec-plans/release-v1.121.0-plan.md` - Codex App GUI 对齐执行计划:`internal/exec-plans/codex-app-gui-alignment-plan.md` - Codex 对话兼容重构:`internal/exec-plans/codex-conversation-compat-refactor-plan.md` - Codex 对齐 v1 并行协调:`internal/exec-plans/codex-alignment-v1-coordination-plan.md` @@ -64,6 +65,7 @@ - `@` 命令本地执行纠偏计划:`internal/exec-plans/at-command-local-execution-alignment-plan.md` - Agent QC 运营级测试体系执行计划:`internal/exec-plans/agent-qc-ops-testing-plan.md` - Agent App v2 独立安装与 Runtime 底座拆分执行计划:`internal/exec-plans/agentapp-v2-standalone-runtime.md` +- Plugin v2 current 实施与 Gate B 计划:`internal/exec-plans/plugin-v2-current-plan.md` - AI 图层化设计实现计划:`internal/exec-plans/ai-layered-design-implementation-plan.md` - 图片能力 feature-flag / extension-tool 执行计划:`internal/exec-plans/image-capability-feature-flag-extension-tool-plan.md` - 图片能力 feature-flag / extension-tool 进度:`internal/exec-plans/image-capability-feature-flag-extension-tool-progress.md` diff --git a/internal/exec-plans/refactor-v2-test-phase-2-plan.md b/internal/exec-plans/refactor-v2-test-phase-2-plan.md index 70d6b1299..8caff9b47 100644 --- a/internal/exec-plans/refactor-v2-test-phase-2-plan.md +++ b/internal/exec-plans/refactor-v2-test-phase-2-plan.md @@ -447,3 +447,23 @@ Electron Desktop Host - current adapter 改用仓库已锁定的 Node `smol-toml` 结构化解析器,移除 Python subprocess 和版本假设;未引入 ad-hoc 行解析,也未改变 DeepSWE task schema、workspace 隔离、live authorization 或 verifier ownership。 - 修复后验证:`npx vitest run scripts/harness/deepswe-adapter.test.mjs scripts/harness/deepswe-coding-slice.test.mjs` 为 `26/26`;`npm run harness:deepswe:preflight` 为 Release 20 `61/61`;adapter ESLint、`npm run governance:scripts`、`npm run test:contracts` 全部通过。 - 分类:Node TOML metadata parser 和 adapter fail-closed/verifier evidence 为 `current / closed`;Python `tomllib` 运行时假设为 `dead / deleted / forbidden-to-restore`。完整 DeepSWE 仍是 `diagnostic_true_runs_blocked`:Agnes 选题固定预算内无 non-empty candidate,Pier editable package 已失效且本机无 Docker/Podman/nerdctl/Colima,不能生成 `reward.json`、`ctrf.json` 或 score;第二期整体完成度保持 `80%`。 + +## 41. 2026-08-04 Benchmark 新一轮 Gate B 验收 + +- 目标与写集:复跑 `internal/roadmap/benchmark` 的 current fixture、恢复、cancel/continue、approval/resume、unknown Item、AgentControl cold-restart、`home-hotpath` 和 `SOAK-01`,只新增 `.gstack/benchmark-reports/2026-08-04-benchmark.{md,json}` 与本轮失败证据;未修改业务源码。 +- 真实 Electron 性能观测:通过 `http://127.0.0.1:9223` 确认 Lime/Electron 42.3.3、`window.__LIME_ELECTRON__=true` 和 `electronAPI.invoke`;reload 的 TTFB/FCP/DOM Interactive/Full Load 为 `49/412/316/1424ms`,250 个请求,transfer `505873` bytes,reload console/page error 均为 0。 +- Gate B 通过:history/cache `31/31`、streaming completion `32/32`、fixture guard `99/99`;session `thread/start/read/list/resume`、cancel/continue、approval/resume、unknown Item 均为 current Electron/App Server/read model/GUI 闭环;AgentControl cold-restart 39 条 assertion 全绿,证据分别见 `.lime/qc/gui-evidence/benchmark-20260804/` 和对应 current fixture 目录。 +- `home-hotpath` 保持失败:250ms budget 下 `homeInputToSendDispatchMs` 两次为 `287ms`、`274ms`,7 月成功基线为 `100ms`、`114ms`;provider wait `90ms`、renderer 首次 delta apply `2ms`,turn/read model/GUI/Electron IPC 成功,invoke/page/console error 为 0。归因仍是 Renderer 首发 dispatch 性能回归,不是 provider、IPC、Runtime 或 terminal failure。 +- `SOAK-01` 未通过:10 轮 / 2 次 cold restart 在首轮失败,唯一失败断言为 `waitAgentReturnedTerminalResult`;其他工具、turn terminal 和 `thread/read` 完成。单轮 AgentControl cold-restart 后续通过,不能替代完整 soak receipt。 +- 最后一次 cold-restart recheck 在 `launch-electron` 失败:`canvasImageInsertHistory-BQ3-T3zp.js` 不提供导出 `c`,bridge 未就绪;该证据归类为构建产物/启动环境不一致,需清理并一致构建后复测,当前不直接归因 runtime owner。 +- Gate B 结论:`partial evidence / failed acceptance`。核心链路已证明,但 `home-hotpath`、`SOAK-01` 和 cold-restart recheck 仍是 release evidence blocker;下一刀为 dispatch 性能定位、构建产物一致性复测和 SOAK-01 10x2 完整重跑。 + +## 42. 2026-08-04 Benchmark Gate B 修复后复验 + +- 目标与写集:在第 41 节失败现场上继续复验 `home-hotpath`、AgentControl cold-restart 和 `SOAK-01`;写集为 SOAK 观察器、观察器单测、已有 Renderer dispatch/构建一致性修复及 `.gstack/benchmark-reports/2026-08-04-benchmark.{md,json}`,未修改 Rust RuntimeCore 生产逻辑,未放宽投影 repair 的 fail-closed 断言。 +- SOAK 观察器根因:v2 `thread/list` 的 `Thread` 主键字段是 `id`,不是 `threadId`;旧观察器因此把真实 canonical thread 误判为未列出。请求同时从旧 `includeArchived` 对齐到 v2 `archived: false`,断言现在要求 `id + sessionId` 精确匹配,并由 `tool-execution-soak-evidence.test.mjs` 的 10 条单测覆盖。 +- `home-hotpath` 修复后真实 Electron Gate B 通过:`homeInputToSendDispatchMs=37ms`(预算 250ms),pending preview paint `63ms`,first text paint `543ms`,provider wait `90ms`,renderer 首次 delta apply `1ms`,first delta 到 paint `23ms`;Electron/preload/IPC/App Server/read model/GUI 闭环成功,console/page error 为 0。证据:`.lime/qc/gui-evidence/benchmark-20260804/benchmark-gateb-home-hotpath-after-observer-fix-20260804-summary.json`。 +- 一致 renderer 重建后 AgentControl cold-restart 独立复验通过,旧 `canvasImageInsertHistory` export mismatch 未复现;证据:`.lime/qc/gui-evidence/benchmark-20260804/benchmark-gateb-agent-control-cold-restart-after-renderer-rebuild-20260804.json`。 +- `SOAK-01` 10 轮 / 2 次 cold restart 通过:`allRoundsPassed`、`roundSessionsIsolated`、`readModelsStableAcrossColdRestarts`、`everyPreviousProcessTreeExited`、`finalProcessTreeExited` 全为 `true`;总 RSS `561,312 -> 442,768 KiB`(delta `-118,544 KiB`),App Server RSS delta `+8,976 KiB`,10 轮 duration `2,820-5,371ms`。证据:`.lime/qc/soak-01/agent-control-soak-final.json`。 +- 验证:SOAK evidence 单测 `10/10`、单轮 cold-restart、10x2 SOAK、home-hotpath 及一致构建后的 cold-restart 全部通过;未执行 commit/push/reset/分支操作。 +- 分类与结论:v2 `thread/list` 字段归一化和 SOAK 观察器为 `current / closed`;旧 `threadId` 观察字段与 `includeArchived` 请求为 `dead / deleted / forbidden-to-restore`;历史失败 JSON/截图保留为诊断 evidence。当前本轮 Gate B 为 `passed`,但本地 controlled fixture 不能替代 live provider、Windows RC 或正式签名/notarization 证据。 diff --git a/internal/exec-plans/release-v1.121.0-plan.md b/internal/exec-plans/release-v1.121.0-plan.md new file mode 100644 index 000000000..c91a1e1e6 --- /dev/null +++ b/internal/exec-plans/release-v1.121.0-plan.md @@ -0,0 +1,69 @@ +# Lime v1.121.0 发布执行计划 + +状态:completed +日期:2026-08-05 +目标版本:`1.121.0` +目标 tag:`v1.121.0` + +## 主目标 + +以 `v1.120.1` 为基线,将当前工作树中的 Plugin v2 catalog/安装/启停、Agent runtime activation、Claw mention、MCP App Right Surface、受控 Browser HTML 宿主、Skills watcher 修复及其协议、GUI、测试和文档作为同一 release candidate,完成版本事实源、双语 release notes、发布门禁、release commit、tag、`main`/tag 推送与远端复核。 + +## 当前阶段与下一刀 + +- 当前阶段:版本事实源、双语 release notes、发布门禁、架构图确认及 Git 发布收口均已完成。 +- 下一刀:无;后续由 `v1.121.0` 发布后的线上观察与下一版本执行计划承接。 + +## Release Candidate + +- 基线:`v1.120.1`,本地 `main` 与 `origin/main` 均为 `bba332c65`。 +- 初始盘点:145 个 tracked diff 文件、48 个未跟踪文件;tracked diff 为 7,838 行新增、537 行删除。 +- 门禁期间并行候选补入 canonical store 快照持久化、SQLite busy timeout 与回归测试;发版文件及闭环修复纳入后为 154 个 tracked diff 文件、49 个未跟踪文件,其中 `.gitignore` 仍排除。 +- `release metadata`:`package.json`、`packages/lime-cli-npm/package.json`、`lime-rs/Cargo.toml`、`lime-rs/Cargo.lock`、`RELEASE_NOTES.md`、`RELEASE_NOTES.en.md`、本计划及执行计划索引。 +- `candidate changes`:除 `.gitignore` 外的当前工作树产品、协议、schema、生成客户端、Rust runtime、Electron host/fixture、GUI、五语资源、测试、bundled Plugin 资产与文档改动。 +- `excluded changes`:`.gitignore` 中本地 `.gstack/` 忽略项;该改动已在 `v1.120.1` 发布计划中明确排除,本次继续保留在未提交工作树。 +- 并行避让:除 release metadata 与本计划外,其余候选文件只读验证;门禁暴露 blocker 时只做最小可验证修复并回写本计划。 + +## 写集与退出条件 + +- 版本事实源与双语 release notes 统一为 `1.121.0`,notes 只保留当前版本单页。 +- 必跑 `npm run verify:app-version`、`npm run typecheck`、`npm run test:contracts`、受影响 Rust 验证、`npm run smoke:plugin-v2-current-electron-fixture`、`npm run verify:gui-smoke` 与 `git diff --check`。 +- 候选已修改 `internal/aiprompts/architecture.md` 并补充 Plugin v2 current 图。直接发布 `main` 无 PR 描述,责任开发者必须在 Git 高风险操作确认中同时确认架构图,未确认不得进入 release evidence。 +- Git 写操作(stage、commit、tag、push)须在门禁完成后按危险操作格式取得一次明确确认。 +- 完成 release commit、`v1.121.0` tag、`main` 与 tag 推送后,复核本地与远端引用;`.gitignore` 应作为唯一未提交排除项保留。 + +## 已知非本版阻断项 + +- Windows 安装到卸载证据、360px 级五语最长文案截图矩阵、repo/personal marketplace 的完整用户路径、更新/授权/管理员策略与安装事务崩溃恢复未验收。 +- 通用 Plugin Apps/Hooks、Browser intent、文件/结构化结果与完整 Plugin identity 追踪仍由 `plugin-v2-current-plan.md` 后续阶段承接。 +- 本版只完成上述已实现的 macOS current 候选发布,不将 Plugin v2 整体路线图标记为完成。 + +## 验证记录 + +- `npm run verify:app-version`:通过,根应用、CLI npm 包、Rust workspace/lock 与 Electron/App Server 版本统一为 `1.121.0`。 +- `npm run typecheck`:通过,Renderer 与 Node TypeScript 发布硬门禁无错误。 +- `npm run test:contracts`:通过;862 个协议类型生成失败 0/漂移 0,App Server client 292 checks,命令、Harness、多模态、脚本、Forge 发布流程与文档边界通过。 +- `npm run test:rust:changed -- --changed=origin/main`:版本/lock 触发后自动扩大为 `cargo test --lib --workspace`;首轮暴露 reasoning 指数重复和两个 Codex 导入性能失败,修复持久化二次 merge 及重复 delta 语义后重跑全 workspace 通过。 +- Rust 定向回归:`thread-store` 重复 reasoning delta/final 快照与空 completion 保留快照 1/1;App Server canonical reasoning 线性持久化、空 completion 保留物化内容及持久化恢复 3/3;Codex 1,200 命令同步导入与 40 turn 后台导入 2/2,分别约 4.0s 与 9.6s。 +- `npm run smoke:plugin-v2-current-electron-fixture`:通过,Gate B/runtime 覆盖 App Center 安装/启停、Claw mention、MCP tool/resource、Right Surface、reload、cold restart、卸载及历史恢复;summary=`.lime/qc/gui-evidence/plugin-v2-current-electron-fixture/plugin-v2-current-electron-fixture-summary.json`。 +- `npm run verify:gui-smoke`:通过,真实 Electron/preload/IPC/`app_server_handle_json_lines`/App Server current 链路、版本 `1.121.0`、reload、Workbench 与 Settings 正常;summary=`.lime/qc/project-gates/standalone-shell-01-20260805091101-56033/shell-01-electron-smoke/summary.json`。 +- `npm run i18n:check:json`:通过,5 个 locale、13 个 namespace、10,063 个源键,缺失/多余均为 0。 +- `cargo fmt --manifest-path "lime-rs/Cargo.toml" --all -- --check`:通过。 +- `git diff --check`:通过。 +- GUI 构建只输出已知非阻断告警:`oem-runtime-config.js` 非 module script、Browserslist 数据过期与 Electron `console-message` API 废弃提示;本次未更新核心依赖。 + +## 架构确认 + +- `internal/aiprompts/architecture.md` 已记录 Plugin v2 catalog、activation snapshot、MCP runtime/Right Surface 和历史恢复主链图。 +- 责任开发者确认:confirmed;Plugin v2 current 图与本次发布候选主链一致。 + +## 分类 + +- `current`:Plugin v2 App Server catalog/install/enabled owner、typed JSON-RPC gateway、activation snapshot、MCP runtime/resource 与 Right Surface current 投影。 +- `compat`:无新增;保留的旧 surface 只能委托 current owner。 +- `deprecated`:Plugin v1 manifest/parser/registry/worker/UI runtime 继续按 `plugin-v2-current-plan.md` 迁出,本次不恢复调用。 +- `dead / deleted / forbidden-to-restore`:无本轮新增删除;生产 mock fallback、Plugin worker 第二 runtime 与 renderer 旧 registry 不得回流 current 主链。 + +## 完成度 + +- 当前发版完成度:100%。候选范围、发版文件、标准门禁、架构图确认、release commit、tag、`main`/tag 推送与远端复核均已完成。 diff --git a/internal/roadmap/browser/README.md b/internal/roadmap/browser/README.md index 831bbafaf..cc29aa625 100644 --- a/internal/roadmap/browser/README.md +++ b/internal/roadmap/browser/README.md @@ -8,10 +8,10 @@ - `internal/roadmap/rightsurface/README.md` - `internal/roadmap/plugin/README.md` -- `internal/roadmap/plugin/prd.md` -- `internal/roadmap/plugin/architecture.md` -- `internal/roadmap/plugin/interface-contracts.md` -- `internal/roadmap/plugin/technical-baseline.md` +- `internal/roadmap/plugin/v2/01-product-contract.md` +- `internal/roadmap/plugin/v2/03-architecture-and-command-contracts.md` +- `internal/roadmap/plugin/v2/04-app-center-and-claw-surfaces.md` +- `internal/roadmap/plugin/v2/07-verification-contract.md` - `internal/roadmap/agentworkbench/README.md` - `internal/aiprompts/commands.md` - `internal/aiprompts/quality-workflow.md` diff --git a/internal/roadmap/plugin/README.md b/internal/roadmap/plugin/README.md index 4ce93a9b9..ddccc5fcd 100644 --- a/internal/roadmap/plugin/README.md +++ b/internal/roadmap/plugin/README.md @@ -1,91 +1,58 @@ -# Lime 插件路线图 +# Lime Plugin 路线图 -更新时间:2026-06-27 -状态:Skeleton Implemented / Productization In Progress +更新时间:2026-08-04 -## 1. 定位 +状态:`Plugin v2 proposed / implementation-ready` -Lime 的一级产品概念统一为 **插件**。插件负责安装、授权、发布、启用与分发;`插件工作区能力` 只作为插件内部的一种能力形态,表示“带独立 UI 的工作台能力”。旧 `Plugin / 工作台应用` 命名只允许作为迁移输入或历史证据,不再作为插件中心的产品根对象。 +## 当前事实源 + +Plugin 当前路线图统一进入 [v2/README.md](./v2/README.md)。v2 直接采用 Codex-compatible `.codex-plugin/plugin.json`、marketplace、Skills、MCP servers/apps 与 Hooks 模型,并固定以下生产主链: ```text -插件 = 分发与授权根对象 -插件工作区能力 = 插件内部 UI 子能力 -Right Surface = Host 管理的产物渲染工作区 +Electron Desktop Host + -> App Server JSON-RPC + -> RuntimeCore / MCP / Skills / Hooks + -> Thread / Turn / Item projection + -> App Center / Claw / Right Surface ``` -这条路线参考上游插件与市场模型:插件使用 `@` 稳定 ID,marketplace 下发 manifest 摘要、安装策略和认证策略,用户通过显式选择或 `@` 调用,而不是靠语义猜测。 +App Center 参考 Codex 的信息架构和状态语义,但继续使用 Lime 当前主题。Claw 通过 `@plugin` 调用能力,并在唯一 Right Surface 中承载 MCP/App UI、Browser、结构化结果和文件预览。 -服务端 marketplace 事实源在 LimeCore: +## 阅读顺序 -```text -LimeCore 外仓 plugin roadmap / control-plane 文档 -GET /api/v1/public/tenants/{tenantId}/client/plugins/marketplace -``` +1. [研究结论](./v2/00-research-findings.md) +2. [产品合同](./v2/01-product-contract.md) +3. [包、市场与安装](./v2/02-package-marketplace-installation.md) +4. [架构与命令合同](./v2/03-architecture-and-command-contracts.md) +5. [App Center 与 Claw Surface](./v2/04-app-center-and-claw-surfaces.md) +6. [迁移与清理账本](./v2/05-migration-and-cleanup.md) +7. [实施计划](./v2/06-implementation-plan.md) +8. [Gate B 验收合同](./v2/07-verification-contract.md) -Lime Desktop 只负责本地安装态、显式激活、Right Surface 渲染和 App Server prompt context;不在 Lime App Server 内新增 marketplace 服务端。 +## Legacy 声明 -## 2. 文档索引 +本目录根部旧 PRD、架构、接口、原型、证据和实施跟踪文件保留为 `historical reference`,用于回看产品洞察、交互方案、历史决策和既有证据。它们不再是 current 设计、实现或验收事实源,也不得独立继续演进。 -| 文档 | 用途 | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| [`prd.md`](./prd.md) | 插件产品定义、背景、用户路径、需求、里程碑和验收。 | -| [`architecture.md`](./architecture.md) | 插件/工作区能力/Renderer/Claw/Right Surface 的分层架构。 | -| [`technical-baseline.md`](./technical-baseline.md) | 插件运行的宿主基线、承载方式、current / deprecated / dead 分类。 | -| [`interface-contracts.md`](./interface-contracts.md) | 插件 manifest、激活上下文、renderer contract、surface action contract。 | -| [`implementation-plan.md`](./implementation-plan.md) | 插件中心、composer 激活、renderer host、迁移收口的实施顺序。 | -| [`../../tech/plugin/README.md`](../../tech/plugin/README.md) | Lime Plugin Package v1 技术标准:`plugin.json`、runtime/workbench、skills、worker 和验证口径。 | -| [`history-product-workspace.md`](./history-product-workspace.md) | 历史对话、插件上下文和产物 tab 的恢复规则。 | -| [`user-operations-guide.md`](./user-operations-guide.md) | 插件中心消费、授权、本地安装态、显式激活、Right Surface 和运营排查指南。 | -| [`e2e-evidence.md`](./e2e-evidence.md) | 第二轮跨仓端到端证据包,串联 LimeCore 发布 / 审计与 Lime Desktop GUI fixture。 | -| [`evidence/plugin-productization-e2e-summary.json`](./evidence/plugin-productization-e2e-summary.json) | 本轮桌面 GUI fixture 的版本化精简 summary。 | -| [`prototype.html`](./prototype.html) | 可直接打开的静态 HTML 原型。 | -| [`prototype.md`](./prototype.md) | 插件中心、插件详情、激活 strip、右侧 dock 的低保真原型。 | +旧 `lime.plugin.package.v1`、根 `plugin.json`、`contributions.runtime/workbench`、插件专用 worker 和 renderer registry 仍处于 `deprecated`,不得新增调用或能力;只有 current 主链迁移和 Gate B 验收完成后才能删除。文档保留与旧实现退役是两个独立治理维度,详见 [迁移与清理账本](./v2/05-migration-and-cleanup.md)。 -## 2.1 图表索引 +## 历史参考导航 -| 图表 | 位置 | 用途 | -| ---------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| 跨仓职责图 | [`architecture.md`](./architecture.md#31-跨仓职责图) | 固定 LimeCore marketplace、Lime Desktop、本地 registry、App Server prompt context 与 Right Surface 的职责边界。 | -| 分层架构图 | [`architecture.md`](./architecture.md#4-分层架构) | 固定 Plugin Contract、Runtime、Session Plugin Workspace、Right Surface 的分层。 | -| 显式激活时序图 | [`architecture.md`](./architecture.md#显式激活时序图) | 描述 `@plugin` 从 composer 到 App Server prompt context 的时序。 | -| Marketplace 消费流程图 | [`architecture.md`](./architecture.md#marketplace-消费流程图) | 描述客户端拉取 LimeCore marketplace、合并本地安装态和构造 activation metadata。 | -| 历史恢复拓扑 | [`architecture.md`](./architecture.md#8-历史恢复拓扑) | 描述历史会话、plugin workspace 和 Right Surface 恢复关系。 | -| Surface Action 回流图 | [`architecture.md`](./architecture.md#9-surface-action-回流) | 固定右侧 action 必须回流 runtime,不直连 provider 或文件系统。 | -| 开发切片总览 | [`implementation-plan.md`](./implementation-plan.md#2-开发切片总览) | 跟踪从文档 contract 到内容工厂 dogfood 的实施顺序。 | -| 客户端消费流程图 | [`user-operations-guide.md`](./user-operations-guide.md#3-端到端消费流程) | 描述插件中心拉取、合并本地 registry、安装、上报和显式激活。 | -| 显式激活时序图 | [`user-operations-guide.md`](./user-operations-guide.md#4-用户路径) | 描述 `@插件` 从 UI 到 App Server current 运行链的时序。 | -| 安装态与审计流程图 | [`user-operations-guide.md`](./user-operations-guide.md#7-安装态与审计) | 描述本地 installed registry 与 LimeCore 审计之间的边界。 | -| 跨仓证据流程图 | [`e2e-evidence.md`](./e2e-evidence.md#2-跨仓证据流程图) | 描述平台发布、客户端消费、安装态报告、显式激活、Right Surface 和审计证据。 | +### 产品与设计 -## 3. 当前决策 +- [旧版 PRD](./prd.md) +- [旧版架构](./architecture.md) +- [旧版接口合同](./interface-contracts.md) +- [旧版技术基线](./technical-baseline.md) +- [旧版交互原型说明](./prototype.md)与[静态原型](./prototype.html) +- [历史工作区与恢复设计](./history-product-workspace.md) +- [旧版用户操作指南](./user-operations-guide.md) -| 决策 | 口径 | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 根对象 | 插件是用户侧可安装、可授权、可发布的根对象。 | -| Marketplace 服务端 | `current` 在 LimeCore control-plane,不在 Lime App Server。 | -| 插件工作区能力 | 作为插件内独立 UI 能力;旧 Plugin / 工作台应用只允许作为迁移输入,不再作为插件 marketplace 的设计模板。 | -| 右侧渲染 | Right Surface 继续作为 Host 的唯一物理右栏,插件只提供数据模型、视图与 action。 | -| 激活方式 | 不再在每次发送消息时全量读取插件列表做语义猜测;激活必须显式。 | -| Renderer 输出合同 | manifest / marketplace summary 可以声明 `outputArtifactKind`、`paneKind` 和 action,但 runtime 当前只接管内容工厂 workspace patch,不开放任意插件执行。 | -| 内容工厂 | 内容工厂应作为插件重建,而不是复用旧 `旧内容工作台` 代码。 | -| 现有路线上下文 | 现有 `rightsurface` 负责统一右侧 dock;plugin 路线只定义该 dock 如何承载插件产物。 | -| 旧插件中心命令 | 历史插件中心命令族继续按 `dead` 处理,不恢复为生产入口。 | -| 旧 Plugin 目录 | `client/plugins` 和本地 Plugin manifest 投影只允许作为 compat / migration 输入;插件中心 current 数据源是 `client/plugins/marketplace` 与本地插件安装态。 | -| `@` 命令边界 | 平台 `@` 原子命令仍以 `SkillCatalog.entries.kind=command` 为事实源;插件只可贡献显式 activation command entry,并通过 `agentSession/turn/start` metadata 进入 current 主链。 | +### 历史跟踪与证据 -## 4. 与现有路线图关系 +- [旧版实施计划](./implementation-plan.md) +- [旧版 E2E 证据](./e2e-evidence.md) +- [历史 E2E 摘要](./evidence/plugin-productization-e2e-summary.json) +- [旧发布中心 PRD](./deverlop/plugin-publish-center-prd.md) +- [旧发布服务端计划](./deverlop/plugin-publish-limecore-server-plan.md) -| 路线图 | 关系 | -| ---------------------------------------------------------------------- | ------------------------------------------------------------------- | -| [`internal/roadmap/rightsurface/README.md`](../rightsurface/README.md) | 定义唯一右侧 dock、多 tab 和 pane 状态机;plugin 路线复用这层宿主。 | -| [`internal/roadmap/workbench/v4/README.md`](../workbench/v4/README.md) | 定义插件工作区能力在插件体系里的位置,以及内容工厂的工作台形态。 | -| [`internal/roadmap/workbench/v3/README.md`](../workbench/v3/README.md) | 作为历史参考,帮助理解从 Workbench Profile 过渡到插件分层的原因。 | - -## 5. 开发者阅读顺序 - -1. 先读 [`prd.md`](./prd.md)。 -2. 再读 [`architecture.md`](./architecture.md)。 -3. 然后读 [`technical-baseline.md`](./technical-baseline.md) 了解宿主边界。 -4. 对照 [`interface-contracts.md`](./interface-contracts.md) 固定 contract。 -5. 落包结构、skills、worker 或 workbench 时,回到 [`../../tech/plugin/README.md`](../../tech/plugin/README.md) 作为技术事实源。 -6. 按 [`implementation-plan.md`](./implementation-plan.md) 分阶段落地。 +从历史文档复用的有效决策必须先写入 v2 对应合同并通过 current owner 审核,不能让实现直接同时依赖 v1 与 v2。 diff --git a/internal/roadmap/plugin/v2/00-research-findings.md b/internal/roadmap/plugin/v2/00-research-findings.md new file mode 100644 index 000000000..bdc2b44d4 --- /dev/null +++ b/internal/roadmap/plugin/v2/00-research-findings.md @@ -0,0 +1,238 @@ +# Plugin v2 研究结论 + +状态:`research-complete` + +取证日期:2026-08-04 + +## 研究问题 + +本次研究回答四个问题: + +1. Codex 现在如何定义、发现、安装和运行插件? +2. ChatGPT/Codex Desktop 的真实安装包如何组织 bundled plugins? +3. Lime 当前实现与 Codex 的关键差异是什么? +4. 哪些差异属于 Lime 产品特性,哪些只是应删除的历史自定义协议? + +## 官方文档结论 + +本次使用当前 Codex Manual 并检索以下官方页面: + +- [Build plugins](https://learn.chatgpt.com/docs/build-plugins) +- [Package your plugin](https://developers.openai.com/plugins/build/plugins) +- [Plugin architecture](https://developers.openai.com/plugins/concepts/plugins) +- [Build an MCP server](https://developers.openai.com/plugins/build/mcp-server) +- [Connect and test your plugin](https://developers.openai.com/plugins/deploy/connect-chatgpt) +- [Plugins](https://learn.chatgpt.com/docs/plugins) +- [Skills and plugins](https://learn.chatgpt.com/docs/skills-and-plugins) +- [Plugin controls](https://learn.chatgpt.com/docs/enterprise/apps-and-connectors) + +文档给出的稳定结论: + +- Plugin 是可安装的能力包,可以包含 Skills、MCP server/apps、Hooks、assets 与展示元数据。 +- Skill 适合可复用指令与资源;MCP 适合实时数据、鉴权、受控动作或远端执行;UI 只在结构化交互确有价值时加入 MCP server。 +- ChatGPT 与 Codex 使用同一个公共插件目录,但不同产品 surface 的可用性和安装控制仍分开。 +- 插件安装不等于 connector 已授权,也不等于 runtime 获得文件、网络或命令权限。 +- 安装后应在新 conversation/thread 中验证,以确保 Skills 和工具清单按新状态加载。 + +## 本机 Desktop 安装包证据 + +### 应用身份 + +```text +Application: /Applications/ChatGPT.app +Bundle identifier: com.openai.codex +Version: 26.727.51351 +``` + +虽然应用目录名是 `ChatGPT.app`,bundle identifier 与资源都表明它同时承载 Codex Desktop surface。不能依据 Finder 展示名推断产品边界。 + +### 关键资源目录 + +```text +/Applications/ChatGPT.app/Contents/Resources/app.asar +/Applications/ChatGPT.app/Contents/Resources/codex +/Applications/ChatGPT.app/Contents/Resources/codex-code-mode-host +/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled +/Applications/ChatGPT.app/Contents/Resources/skills +/Applications/ChatGPT.app/Contents/Resources/native +``` + +取证时体积约为: + +```text +app.asar: 208 MiB +plugins/openai-bundled: 69 MiB +skills: 372 KiB +``` + +这些目录表明 bundled plugin 不是写死在前端卡片中的 metadata,而是随应用发布的真实包、脚本、Skills、assets 与本地运行资源。 + +### bundled marketplace + +入口文件: + +```text +Resources/plugins/openai-bundled/.agents/plugins/marketplace.json +``` + +取证时包含以下插件: + +- `sites` +- `browser` +- `chrome` +- `computer-use` +- `messages` +- `reminders-macos` +- `record-and-replay` +- `latex` +- `deep-research` +- `visualize` + +marketplace 只描述来源、安装策略、鉴权时机与类别;插件自己的能力和展示信息位于各包的 `.codex-plugin/plugin.json`。 + +### 真实插件包样例 + +Browser 插件使用以下结构: + +```text +plugins/browser/ +├── .codex-plugin/plugin.json +├── assets/ +├── docs/ +├── scripts/ +└── skills/ + └── control-in-app-browser/SKILL.md +``` + +manifest 包含稳定 identity、版本、描述、作者、Skills 路径与 `interface` 元数据;能力脚本和大体积依赖留在插件包内,不进入 renderer bundle。 + +### 用户态目录 + +本机可见的相关位置包括: + +```text +~/Library/Application Support/Codex +~/Library/Application Support/OpenAI/Codex/NativeMessagingHosts +``` + +前者主要是 Chromium/Electron profile、缓存和 browser partition;后者是 Codex 原生消息桥接位置。它们不是 plugin manifest 事实源。 + +官方文档给出的本地作者目录约定为: + +```text +Repo marketplace: $REPO_ROOT/.agents/plugins/marketplace.json +Personal marketplace: ~/.agents/plugins/marketplace.json +Common plugin source: $REPO_ROOT/plugins/ 或 ~/.codex/plugins/ +``` + +`marketplace.json` 的 `source.path` 才是解析依据,示例目录不是必须硬编码的唯一位置。 + +## Codex 源码证据 + +### 领域 owner + +| 路径 | 责任 | +| ------------------------------------------------------- | ------------------------------------------------------- | +| `codex-rs/plugin` | manifest、plugin ID、source authority、resolved plugin | +| `codex-rs/core/plugins` | marketplace discovery、安装、卸载、remote catalog、配置 | +| `codex-rs/utils/plugins` | mention syntax、namespace、MCP connector helper | +| `codex-rs/tools/request_plugin_install.rs` | agent 发起的插件安装请求 | +| `codex-rs/app-server/src/request_processors/plugins.rs` | 对 GUI/CLI 暴露 current plugin API | +| `codex-rs/tui/src/chatwidget/plugins.rs` | 列表、搜索、详情、安装、启停、marketplace UI | + +Codex 的 resolver 先生成 inert descriptor,再由上层决定装配哪些能力。路径资源保留 source authority,避免把来自其他 environment 的资源误当成本机路径。 + +### App Server 方法 + +Codex current 方法包括: + +```text +marketplace/add +marketplace/remove +marketplace/upgrade +plugin/list +plugin/installed +plugin/read +plugin/install +plugin/uninstall +``` + +`plugin/list` 支持 local、vertical、workspace-directory、shared-with-me、created-by-me-remote 等 marketplace kind;`plugin/read` 返回 Skills、Hooks、Apps、MCP servers 与完整展示信息。 + +### 产品状态不是单一布尔值 + +Codex protocol 分开表达: + +- `installed` +- `enabled` +- `installPolicy`: `AVAILABLE | INSTALLED_BY_DEFAULT | NOT_AVAILABLE` +- `authPolicy`: `ON_INSTALL | ON_USE` +- `availability`: `AVAILABLE | DISABLED_BY_ADMIN` +- `disabledReason` +- `version` 与 `localVersion` + +Lime v2 必须保留这些正交维度,不能再用 `installed/disabled/readiness` 的前端拼装近似替代。 + +### UI 交互证据 + +Codex TUI snapshot 和 Desktop `app.asar` 中的真实页面资源共同证明以下 surface 已存在: + +- Plugins 目录页与插件详情页 +- All / Installed / source marketplace 分区 +- 搜索 +- 安装数量摘要 +- 安装、卸载、启用、禁用 +- 管理员分配与管理员禁用 +- Skills、Hooks、Apps、MCP servers 披露 +- 来源、鉴权时机、隐私和条款披露 +- `@plugin` mention 与安装建议 + +Desktop bundle 中对应资源包括 `plugins-page`、`plugin-detail-page`、`plugin-picker-menu-content`、`plugins-settings` 与 `skills-page`。 + +## Lime 当前差异 + +### 包标准分叉 + +Lime 当前 App Server 要求: + +```text +plugin.json +schemaVersion: lime.plugin.package.v1 +contributions.runtime +contributions.workbench +``` + +随后由 `plugin_packages/plugin_manifest.rs` 投影成前端使用的另一份 manifest。这个流程与 Codex `.codex-plugin/plugin.json` 不是同一标准,且该文件已超过 1000 行,混合了解析、projection、兼容和 UI/runtime 推断。 + +### 前端承担过多事实合并 + +`src/features/plugin/marketplace/marketplaceRegistryLoader.ts` 同时读取 marketplace、installed state、manifest、setup state、runtime profile 和 readiness,再组合页面 registry。它实际成为第二个插件事实源。 + +### 自定义 worker 与 UI runtime + +现有 `electron/pluginRuntimeTaskHost.ts`、`pluginTaskWorker.ts`、`src/features/plugin/runtime/**` 和 App Server plugin worker runtime 形成了插件专用执行/投影体系。它与 RuntimeCore、MCP、Skills 和 Right Surface 的 current owner 重叠。 + +### 产品范围膨胀 + +旧路线图把插件消费、独立应用壳、发布中心、云端上传、历史工作区和内容生产 runtime 同时纳入一个计划,导致 App Center 既像 marketplace,又像应用启动器和发布后台。 + +## 对 Plugin v2 的直接结论 + +1. 直接采用 `.codex-plugin/plugin.json`,不设计旧 manifest compat 层。 +2. App Server 返回完整 PluginSummary/PluginDetail,Renderer 不再合并 registry。 +3. 插件能力装配复用 Skills、MCP、Hooks 与 RuntimeCore owner。 +4. `@plugin` mention 是 Claw 的首要调用入口;目录页不是唯一入口。 +5. Right Surface 承载 MCP/App UI 或结果投影,不启动第二套插件业务后端。 +6. 发布平台与消费者 runtime 分离;v2 先完成本地、repo、bundled 与 remote catalog 消费闭环。 +7. 旧文档和 implementation tracker 冻结为历史参考;有效洞察提炼进入 v2,但旧文档不再作为 current 实现依据或独立继续演进。 + +## 研究限制 + +当前会话没有暴露 `computer-use` skill 要求的 Node REPL 工具,因此没有通过自动化直接点击本机 ChatGPT/Codex Desktop 的 Plugins 页面。视觉与交互结论来自: + +- 官方文档 +- 本机真实应用包和 bundle 资源 +- Codex TUI snapshot +- Codex App Server protocol 与实现 + +这足以确定产品结构和协议,但正式 UI 实现前仍需补一轮真实 Desktop 交互对照截图,作为设计输入而不是运行时事实源。 diff --git a/internal/roadmap/plugin/v2/01-product-contract.md b/internal/roadmap/plugin/v2/01-product-contract.md new file mode 100644 index 000000000..4e718b731 --- /dev/null +++ b/internal/roadmap/plugin/v2/01-product-contract.md @@ -0,0 +1,227 @@ +# Plugin v2 产品合同 + +状态:`proposed` + +## 产品定义 + +Plugin 是一个可安装、可治理、可在对话中调用的能力包。它把相关 Skills、MCP servers/apps、Hooks、assets 和展示元数据组织为一个稳定 identity。 + +Plugin 不是: + +- 单独一张应用中心卡片 +- 任意网页 URL +- 另一套 Agent runtime +- 只有 UI、没有可调用能力的 iframe +- 把 Skill、MCP 和 Hook 复制进自己的私有协议 + +## 当前主对象 + +App Center 的主对象是“插件包”;Claw 的主对象仍是“当前 thread/turn”。插件只向 thread 提供可发现能力,不能把用户从对话主线带到第二套任务系统。 + +## 核心用户目标 + +1. 找到可信的插件并理解它能做什么。 +2. 看清来源、权限、鉴权时机和外部影响后安装。 +3. 在 Claw 中通过自然语言或 `@plugin` 明确调用。 +4. 在右侧查看插件返回的结构化 UI、浏览器或结果详情。 +5. 随时启停、更新或卸载,并知道残留数据和授权如何处理。 + +## 来源模型 + +| 来源 | 说明 | 默认信任 | +| ---------- | ---------------------------------------------------- | ------------------------ | +| bundled | 随 Lime 安装包发布并由 release 签名保护 | 高,但仍受运行时权限约束 | +| repo | 当前 workspace 的 `.agents/plugins/marketplace.json` | 仅当前 workspace | +| personal | 用户级 marketplace 和本地插件源 | 当前用户 | +| configured | 用户显式添加的 Git/local/npm marketplace | 按来源和校验结果 | +| workspace | 工作区管理员或远端目录提供 | 按 workspace policy | +| shared | 其他成员分享或链接安装 | 需要展示发布者和分享边界 | + +来源只决定发现和安装路径,不自动授予 connector、文件、网络、命令或 external action 权限。 + +## 生命周期状态 + +### 目录可见性 + +- `available`:可安装。 +- `installedByDefault`:由 bundled/workspace policy 默认安装。 +- `notAvailable`:目录中可解释,但当前不能安装。 +- `disabledByAdmin`:管理员禁止使用。 + +### 本地状态 + +- `notInstalled` +- `installing` +- `installedDisabled` +- `installedEnabled` +- `updateAvailable` +- `uninstalling` +- `failed` + +### 鉴权状态 + +- `notRequired` +- `requiredOnInstall` +- `requiredOnUse` +- `authorizing` +- `authorized` +- `authorizationFailed` + +这些状态必须保持正交。一个插件可以“已安装但未授权”,也可以“管理员默认安装但用户禁用”。 + +## 关键流程 + +### 目录安装 + +```text +打开 App Center + -> 选择来源或搜索 + -> 打开详情 + -> 查看能力、权限、来源与条款 + -> 安装 + -> 必要时授权 connector + -> 新建或刷新 thread + -> 在 Claw 中调用 +``` + +### Claw 中安装建议 + +```text +用户输入 @plugin 或提出明确目标 + -> installed projection 未找到能力 + -> 目录 projection 存在可安装候选 + -> 展示安装建议 + -> 用户确认安装 + -> 必要时授权 + -> 新 thread/turn 生效 +``` + +Agent 可以请求安装,但不能绕过安装确认、管理员策略或 connector 授权。 + +### 右侧工作区 + +```text +插件工具返回 structured content / UI resource + -> RuntimeCore 记录 tool item + -> GUI projection 解析 surface descriptor + -> Claw Right Surface 打开对应面板 + -> 用户在同一 thread 继续确认或操作 +``` + +右侧关闭后,对话与 tool item 仍可恢复;右侧不能成为独立于 thread 的隐藏状态源。 + +## App Center 信息架构 + +一级结构: + +- 全部 +- 已安装 +- 官方/bundled +- Workspace +- Repo/Local +- 用户添加的 marketplace + +主要动作: + +- 未安装:`安装` +- 已安装且启用:`在 Claw 中使用` +- 已安装且禁用:`启用` +- 有更新:`更新` +- 详情更多操作:`卸载`、`查看来源`、`管理授权` + +不在卡片上同时放置发布、审核、导出、删除数据等低频管理动作。 + +## 详情披露合同 + +详情必须展示: + +- 名称、图标、开发者、版本 +- 一句话能力说明与完整说明 +- 来源 marketplace 和安装策略 +- Skills +- MCP servers/apps +- Hooks 及触发事件 +- 需要的 connector 与鉴权时机 +- Read/Write/Interactive 等能力标签 +- 网站、隐私政策和服务条款 +- 安装/启停/更新/卸载状态 + +技术细节可以折叠,但外部写入、敏感数据访问和不可逆动作不能隐藏。 + +## Claw 兼容合同 + +### Composer + +- `@` picker 同时显示已安装插件和明确标注的可安装建议。 +- 已安装插件可插入 `plugin://` 结构化 mention,显示文本使用 `@DisplayName`。 +- 未安装插件不能伪装成已可调用;选中后进入安装确认。 +- mention identity 必须贯穿 turn request、tool selection、trace 和历史恢复。 + +### Runtime + +- Plugin mention 只增加插件选择上下文,不绕过 Skill/MCP/Tool 选择与权限检查。 +- 未显式 mention 时仍可按 Skill 描述和工具 metadata 自动发现。 +- 同一插件的 Skills、MCP 与 Hooks 使用同一 plugin identity 和 source authority。 + +### Right Surface + +- 支持 MCP/App UI resource。 +- 支持 browser intent 与现有 Browser Right Surface。 +- 支持结构化结果、文件预览和审查视图。 +- 不支持插件自行启动未受管 iframe、任意本地 server 或 renderer-to-worker 私有协议。 + +## 启用与安装的语义 + +- 安装:把受校验的包和来源写入 installed store。 +- 启用:允许其能力进入新 thread 的 discovery/activation projection。 +- 授权:允许 connector 代表当前身份访问外部系统。 +- 运行权限:决定当前 turn 是否可执行文件、网络、命令或外部写操作。 + +这四件事不得合并为一个“可用”按钮。 + +## 错误与恢复 + +每个失败必须给出可执行恢复动作: + +| 失败 | 恢复动作 | +| -------------------- | ------------------------------------ | +| marketplace 无法读取 | 重试、刷新来源、查看具体来源错误 | +| manifest 无效 | 显示字段/路径错误,不允许安装 | +| 版本不兼容 | 显示所需 host/runtime 版本 | +| connector 未授权 | 进入授权,不重新安装插件 | +| 管理员禁用 | 说明策略来源,不提供无效启用按钮 | +| MCP 启动失败 | 查看连接诊断、重试或禁用该 connector | +| 安装中断 | 回滚 staging,不留下半安装状态 | +| 卸载有残留 | 显示残留位置和重试入口 | + +## 首版范围 + +P0/P1 必须支持: + +- bundled/repo/personal/configured local marketplace +- `.codex-plugin/plugin.json` +- skills-only plugin +- MCP plugin,包括 auth-on-install 与 auth-on-use +- Hooks 声明和信任披露 +- App Center 目录、详情、安装、启停、卸载 +- `@plugin` mention +- Claw Right Surface 的 MCP/App UI + +后续再支持: + +- public universal directory 发布 +- workspace sharing 与 groups +- remote billing/commerce +- scheduled tasks +- 插件作者 analytics + +## 产品验收问题 + +发布前必须能明确回答: + +1. 用户正在处理哪个插件、来自哪里? +2. 它为什么可用、不可用或被禁用? +3. 安装后还需要什么授权? +4. 在 Claw 中如何调用? +5. 右侧内容属于哪个 thread/turn/tool item? +6. 卸载后包、配置、授权和数据各自如何处理? diff --git a/internal/roadmap/plugin/v2/02-package-marketplace-installation.md b/internal/roadmap/plugin/v2/02-package-marketplace-installation.md new file mode 100644 index 000000000..d980b488b --- /dev/null +++ b/internal/roadmap/plugin/v2/02-package-marketplace-installation.md @@ -0,0 +1,293 @@ +# Plugin v2 包、Marketplace 与安装合同 + +状态:`proposed` + +## 唯一包结构 + +Plugin v2 只接受 Codex-compatible package: + +```text +my-plugin/ +├── .codex-plugin/ +│ └── plugin.json +├── skills/ +│ └── my-workflow/ +│ ├── SKILL.md +│ ├── references/ +│ ├── scripts/ +│ └── assets/ +├── .mcp.json # 可选 +├── .app.json # 可选,兼容 MCP/App connection 映射 +├── hooks.json # 可选 +└── assets/ # 可选,logo/icon/screenshots +``` + +旧根目录 `plugin.json`、`schemaVersion: lime.plugin.package.v1`、`contributions.runtime`、`contributions.workbench` 不进入 v2 parser。 + +## Manifest 合同 + +最小 skills-only 插件: + +```json +{ + "name": "meeting-follow-up", + "version": "1.0.0", + "description": "Turn meeting notes into decisions and next steps", + "skills": "./skills/" +} +``` + +完整示例: + +```json +{ + "name": "acme-projects", + "version": "1.2.0", + "description": "Review and update Acme projects", + "author": { + "name": "Acme" + }, + "homepage": "https://example.com/docs", + "repository": "https://example.com/repo", + "license": "MIT", + "keywords": ["projects", "planning"], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "apps": "./.app.json", + "hooks": "./hooks.json", + "interface": { + "displayName": "Acme Projects", + "shortDescription": "Review projects and blockers", + "longDescription": "Inspect project status, review blockers, and update approved fields.", + "developerName": "Acme", + "category": "Productivity", + "capabilities": ["Read", "Write", "Interactive"], + "websiteURL": "https://example.com", + "privacyPolicyURL": "https://example.com/privacy", + "termsOfServiceURL": "https://example.com/terms", + "defaultPrompt": ["Show blockers in my active projects"], + "brandColor": "#0F766E", + "composerIcon": "./assets/composer.png", + "logo": "./assets/logo.png", + "logoDark": "./assets/logo-dark.png", + "screenshots": ["./assets/overview.png"] + } +} +``` + +## 字段规则 + +### Identity + +- `name`:必填,kebab-case,作为 package identity 与 namespace 基础。 +- `version`:发布包必填,严格 semver;本地开发包也建议必填。 +- 同一 marketplace 内 `name` 唯一。 +- installed identity 不能只用 `name`;至少组合 source identity 与 plugin name,远端包还保留 remote plugin ID。 + +### Components + +- `skills`:包内相对路径,默认发现 `skills/`。 +- `mcpServers`:包内 `.mcp.json` 路径或内联 server object。 +- `apps`:包内 `.app.json`,指向已注册或可解析的 app/connector declaration。 +- `hooks`:包内 hook config;启用前必须做信任和事件披露。 +- 所有路径都必须解析到 package root 内,禁止绝对路径、`..` 越界、符号链接逃逸与大小写/Unicode normalization 冲突。 + +### Interface + +- `displayName` 是用户可见名称;缺失时回退 `name`。 +- `shortDescription` 用于列表;`longDescription` 用于详情。 +- `defaultPrompt` 最多展示 3 条,每条最多 128 字符。 +- logo、icon 和 screenshots 必须存在于包内。 +- `brandColor` 只用于局部品牌提示,不覆盖 Lime 全局主题。 +- 外部写入或交互能力必须来自实际组件声明,不允许只靠 marketing metadata 宣称。 + +## Marketplace 合同 + +Repo marketplace: + +```text +$REPO_ROOT/.agents/plugins/marketplace.json +``` + +Personal marketplace: + +```text +~/.agents/plugins/marketplace.json +``` + +示例: + +```json +{ + "name": "local-team", + "interface": { + "displayName": "Team Plugins" + }, + "plugins": [ + { + "name": "acme-projects", + "source": { + "source": "local", + "path": "./plugins/acme-projects" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} +``` + +### 支持来源 + +- `local`:marketplace root 内的相对目录。 +- `url`:插件位于 Git repository 根目录。 +- `git-subdir`:插件位于 Git repository 子目录,可带 `ref` 或 `sha`。 +- `npm`:包名、版本范围和 HTTPS registry;下载时禁止执行 lifecycle scripts。 +- `remote`:workspace/public catalog 返回的不可伪造 remote identity。 + +### Policy + +- `installation`: `AVAILABLE | INSTALLED_BY_DEFAULT | NOT_AVAILABLE` +- `authentication`: `ON_INSTALL | ON_USE` +- `products`:仅作为显式 surface override;默认省略。 +- `category`:目录分组,不参与权限判断。 + +## Discovery 顺序 + +App Server 根据当前 workspace roots 计算 discovery: + +1. release-bundled marketplace +2. 当前 repo marketplace +3. personal marketplace +4. 用户显式配置的 marketplace snapshots +5. workspace/remote catalog + +顺序只影响展示与冲突诊断,不允许用“后读覆盖前读”静默替换同 identity 包。冲突必须返回 source-aware diagnostics。 + +Renderer 不读取任何 marketplace 文件,也不自行扫描 `plugins/`。 + +## Installed Store + +installed store 至少保存: + +```text +pluginId +pluginName +sourceKind +sourceIdentity +marketplaceIdentity +remotePluginId? +installedVersion +contentDigest +installedAt +enabled +installPolicy +authPolicy +packageRootLocator +componentSummary +``` + +不要把 connector access token、OAuth refresh token 或用户数据写进 installed record。凭证进入统一 secret/credential owner。 + +## 安装事务 + +```text +resolve source + -> fetch/copy into isolated staging + -> validate marketplace policy + -> validate manifest and all paths + -> compute package digest + -> inspect Skills/MCP/Apps/Hooks + -> produce install review + -> user confirmation + -> atomic materialization + -> write installed store + -> start required auth flow + -> emit pluginsChanged +``` + +### Install review + +确认界面必须披露: + +- 插件名称、版本、开发者和来源 +- 安装策略与 auth policy +- Skills 数量与名称 +- MCP servers/apps +- Hook 事件与信任状态 +- Read/Write/Interactive 能力 +- 需要的外部授权 +- 包 digest 或签名状态 + +### 原子性 + +- staging 与 final package root 必须在同一可原子替换的存储边界。 +- 任一步失败都清理 staging,installed store 保持原值。 +- 更新使用 side-by-side staging,验证通过后替换,不原地覆盖运行中的包。 +- Runtime 使用 version/digest pin;正在运行的 turn 不切换到半更新组件。 + +## 启用与刷新 + +- 安装后默认是否启用由 marketplace policy 和用户确认共同决定。 +- enabled state 只影响新 thread/turn 的能力 snapshot。 +- 改变启用状态后发出 `pluginsChanged`,Claw 提示新 conversation/thread 生效。 +- 不在运行中的 turn 动态增删工具定义,避免 model context 与 tool registry 不一致。 + +## 更新 + +更新检查比较 source version、installed version 与 local materialized version: + +- 同 digest 不重复安装。 +- semver 降级需要显式确认。 +- source identity 改变视为新安装,不静默更新。 +- Git branch/range 可以刷新,但 installed record 必须 pin 到解析后的 commit/digest。 +- npm 安装不运行 `preinstall/postinstall` 等生命周期脚本。 + +## 卸载 + +卸载分四类资源: + +1. package materialization +2. installed/enabled configuration +3. connector authorization +4. plugin-owned user data + +默认卸载只删除 1 和 2;3 和 4 必须在 UI 中单独说明和选择。管理员默认安装的插件不可由用户卸载,只能按 policy 允许时禁用。 + +卸载必须先停止该插件的 MCP process/hooks,等待 terminal 或明确中止,再删除 package。正在运行的 turn 保留历史 item,不因卸载被改写。 + +## 平台路径 + +生产代码不得直接拼接 `$HOME`、`~/Library` 或 Windows profile 路径。所有位置通过统一 app paths/platform API 解析: + +- bundled source:应用 Resources 内只读目录 +- marketplace snapshots:用户 app data/config owner +- installed packages:用户 app data 下的 plugin store +- staging/cache:统一 cache/temp owner +- secrets:系统 keychain/credential owner + +macOS 和 Windows 使用相同逻辑 identity,不要求物理路径一致。 + +## 安全边界 + +- manifest 与 marketplace 都是不可信输入。 +- 路径必须 canonicalize 后验证仍在 authority root 内。 +- remote download 必须有大小、超时、content-type、digest/signature 与重定向限制。 +- archive 解包防 zip-slip、symlink/hardlink escape、大小炸弹和文件数量炸弹。 +- MCP/Hook/Script 的存在不意味着可执行;执行受 runtime policy 和 approval 控制。 +- UI resource 使用受控 origin/CSP,不接受任意 `file://` 或 unrestricted localhost。 +- 日志只保存 identity、digest、阶段和错误码,不保存 token、完整用户数据或 tool result。 + +## 明确删除的旧合同 + +Plugin v2 完成迁移后,下列合同为 `dead / forbidden-to-restore`: + +- `lime.plugin.package.v1` +- 根 `plugin.json` 的 `contributions.runtime/workbench` +- `app.runtime.yaml` 作为插件 runtime 主入口 +- Renderer 自行 projection manifest +- 以本地完整路径作为跨层 plugin identity +- 安装记录中混合凭证、运行状态和 UI readiness diff --git a/internal/roadmap/plugin/v2/03-architecture-and-command-contracts.md b/internal/roadmap/plugin/v2/03-architecture-and-command-contracts.md new file mode 100644 index 000000000..f2b2fa314 --- /dev/null +++ b/internal/roadmap/plugin/v2/03-architecture-and-command-contracts.md @@ -0,0 +1,285 @@ +# Plugin v2 架构与命令合同 + +状态:`implementation in progress` + +## 唯一产品链 + +```text +Electron Desktop Host + -> app_server_handle_json_lines + -> App Server plugin JSON-RPC + -> Plugin catalog/install owner + -> Skills / MCP / Hooks activation + -> RuntimeCore Thread/Turn/Item + -> Renderer projection + -> App Center / Claw / Right Surface +``` + +Electron 只负责 desktop host、文件选择、窗口/webContents 和受控原生能力;它不解析 marketplace、不决定 plugin readiness,也不运行第二套 plugin task runtime。 + +## Owner 分工 + +| Owner | 责任 | 禁止承担 | +| ------------------------ | ------------------------------------------------------- | ----------------------------- | +| `app-server-protocol` | plugin/marketplace JSON-RPC schema、notification | 文件扫描、安装实现 | +| App Server plugin domain | discovery、read、install、update、uninstall、projection | provider request、UI 状态拼装 | +| app paths/repository | 跨平台目录、installed store、原子写入 | 运行时工具执行 | +| Skills owner | Skill discovery、namespace、selection、prompt fragment | marketplace 安装 | +| MCP owner | server lifecycle、OAuth、tools/resources/apps | plugin catalog UI | +| Hooks owner | hook config、trust、lifecycle enforcement | 任意插件私有 worker | +| RuntimeCore | thread/turn snapshot、工具与审批、item projection | 安装包文件操作 | +| Renderer gateway | typed JSON-RPC client、notification subscription | manifest 解析、registry 合并 | +| App Center | 目录、详情和生命周期操作 | catalog 事实源 | +| Claw Right Surface | UI/result projection | 独立业务后端 | + +## 协议方法 + +Plugin v2 采用下列 current 方法: + +```text +marketplace/add +marketplace/remove +marketplace/upgrade +plugin/list +plugin/installed +plugin/read +plugin/install +plugin/uninstall +plugin/enabled/set +``` + +`plugin/enabled/set` 是 Lime 需要的明确扩展,用于表达本地启停;其语义不得混入 install/uninstall。 + +后续按真实需求再加入: + +```text +plugin/update +plugin/share/save +plugin/skill/read +``` + +不要把旧 `pluginInstalledSave`、`pluginLocalPackageInspect`、`pluginUiRuntimeStart` 等方法机械改名后继续保留同一实现。 + +## 核心 DTO + +### PluginSummary + +```text +id +remotePluginId? +name +version? +localVersion? +source +installed +installedAt? +enabled +installPolicy +installPolicySource? +authPolicy +availability +disabledReason? +interface? +keywords[] +``` + +### PluginDetail + +```text +marketplaceName +marketplacePath? +summary +description? +skills[] +hooks[] +apps[] +mcpServers[] +screenshots[] +sourceDiagnostics[] +``` + +### Source authority + +资源 locator 必须携带 owner: + +```text +Bundled { releaseId, relativePath } +Workspace { workspaceId, relativePath } +Local { marketplaceId, relativePath } +Installed { pluginId, digest, relativePath } +Remote { remotePluginId, releaseId, resourceId } +``` + +跨 App Server 边界不暴露可被 Renderer 任意读取的绝对路径。需要展示来源时返回 sanitized label 和可审计 source descriptor。 + +## Discovery 与缓存 + +App Server 每次 list 以 workspace roots、configured marketplaces、release bundle 和 remote policy 为输入,生成 source-aware projection。 + +缓存必须以以下维度键控: + +```text +workspace roots +marketplace snapshot versions +installed store revision +workspace/admin policy revision +remote catalog revision +``` + +不得只按 plugin name 缓存,也不得由 Renderer localStorage 充当 installed fact。 + +## 安装时序 + +```text +Renderer plugin/install + -> App Server resolve marketplace entry + -> installer staging + validation + -> optional confirmation/authorization request + -> atomic store update + -> activate component descriptors + -> pluginsChanged notification + -> Renderer refresh list/detail +``` + +安装请求必须包含 marketplace identity 与 plugin name,不能只传 URL 或任意文件路径。导入本地目录时先建立显式 local marketplace/source authority,再走同一安装流程。 + +## Runtime 装配 + +### Thread snapshot + +启动或恢复 thread 时,RuntimeCore 获取当前 enabled plugins snapshot: + +- plugin identity/version/digest +- enabled Skills roots +- MCP server/app declarations +- trusted Hooks +- policy and auth readiness + +snapshot 写入 thread/runtime metadata,只保存 marker 和 identity,不保存完整 Skill body、secret 或 MCP token。 + +### Turn + +- 显式 `@plugin` mention 进入 turn input 的结构化 selection。 +- Skills selector 可从 enabled plugin skill roots 发现候选。 +- MCP tool registry 只暴露已启动、已授权且 policy 允许的 server tools。 +- Hooks 在 tool/runtime owner 的统一 lifecycle 上执行。 +- 安装/启停变化不修改正在运行的 turn tool schema。 + +### MCP current 装配 + +Plugin v2 的 MCP 声明已进入统一 runtime owner: + +```text +LocalAppDataSource + -> list_mcp_runtime_server_specs + -> RuntimeBackend::ensure_thread_mcp_runtime_if_available + -> AgentRuntimeState::ensure_mcp_runtime + -> McpThreadRuntime + -> McpClientManager +``` + +当前合同如下: + +- manifest `mcpServers` 可以指向包内 JSON 文件,也可以直接声明 inline object;manifest 未声明时发现 package root 的 `.mcp.json`。 +- `.mcp.json` 同时接受 `{ "mcpServers": {...} }` 和直接 server map,并复用 `lime_mcp::McpServerConfig` 解析。 +- stdio server 默认 `cwd` 是 installed package root;显式相对或绝对 `cwd` canonicalize 后必须仍位于该 root 内。 +- disabled Plugin 不生成 runtime spec;单个无效 server fail closed,但不丢弃有效 sibling。 +- runtime server name 固定为 `plugin____`,tool name 继续由统一 MCP owner 投影为 `mcp____`。 +- 用户 MCP 与 Plugin MCP 重名时保留用户配置并跳过冲突的 Plugin server。 +- activation descriptor 记录可审计的 `runtimeCapabilities.mcpServers`,但 `mcpBindings` 不伪造 wildcard tool binding;具体工具只能在 server initialize/list tools 后进入 snapshot。 + +该实现不新增 Plugin worker、第二套 MCP manager 或私有 IPC。Apps 与 Hooks 的 current lifecycle 装配仍待完成。 + +### Item projection + +tool item 必须保留: + +```text +pluginId? +skillName? +mcpServerName? +toolName +callId +approvalId? +surfaceDescriptor? +status +``` + +这样 Claw timeline、Right Surface 和历史恢复使用同一 identity。 + +## Right Surface 合同 + +插件可以通过标准 tool result 声明 surface: + +```text +McpAppResource { server, resourceUri, csp, initialState } +BrowserTarget { url, browserFamily?, ownership } +StructuredResult { rendererKind, dataRef } +FilePreview { pathRef, mimeType } +``` + +Renderer 只根据受支持 descriptor 打开已有 Right Surface owner。插件不能注册任意 React component import、任意 iframe src 或 Electron IPC channel。 + +UI 内的动作回到 MCP tool call/elicitation/approval;不建立 `pluginUiRuntimeStart -> plugin worker -> custom event` 私有闭环。 + +## Auth 与权限 + +```text +Plugin installed + != Connector authorized + != MCP ready + != Tool allowed + != External write approved +``` + +App Server projection 分别返回这些状态。授权由 MCP/OAuth owner 执行,secret 由统一凭证 owner 保存;approval 由 tool-runtime/agent-runtime 执行。 + +## Notifications + +至少需要: + +```text +plugin/changed +marketplace/changed +mcpServer/statusUpdated +mcpServer/oauthLoginCompleted +skills/changed +``` + +通知只表示 revision 改变,Renderer 收到后重新读取 projection;不要把整份 catalog 通过事件重复广播。 + +## 错误模型 + +错误按 owner 分类: + +- `marketplace_*`:来源不可达、格式错误、冲突 +- `package_*`:manifest、路径、digest、archive、版本 +- `policy_*`:管理员禁用、计划不满足、产品不支持 +- `auth_*`:未授权、scope 不足、登录失败 +- `runtime_*`:MCP/Hook/Skill activation 失败 +- `surface_*`:UI resource/CSP/renderer 不支持 + +错误必须包含稳定 code、sanitized message、retryability 与 recovery action;不向 Renderer 泄露 secret 或未清理的本地绝对路径。 + +## 架构删除线 + +以下方向禁止进入 Plugin v2: + +- Renderer -> filesystem 直接扫描 plugin package +- Renderer -> Electron plugin worker 作为业务后端 +- App Center registry 与 RuntimeCore 各维护一份 enabled plugins +- 插件包注册任意 IPC/capability 名称 +- 生产路径回退 mock capability host +- 为旧 `lime.plugin.package.v1` 新增 compat wrapper +- 用 `pluginId@tenant` 字符串拼装代替结构化 source identity + +## 架构确认项 + +实现阶段的责任开发者需要在执行计划与 PR 描述确认: + +- [ ] App Server 是 catalog/installed 唯一事实源 +- [ ] RuntimeCore 使用同一 plugin identity snapshot +- [ ] Right Surface 没有第二套业务后端 +- [ ] 旧 manifest 和 worker 入口已迁出或删除 +- [ ] macOS/Windows 路径走统一平台 owner +- [ ] Gate B trace 能证明 current JSON-RPC 与用户可见状态 diff --git a/internal/roadmap/plugin/v2/04-app-center-and-claw-surfaces.md b/internal/roadmap/plugin/v2/04-app-center-and-claw-surfaces.md new file mode 100644 index 000000000..4ef802574 --- /dev/null +++ b/internal/roadmap/plugin/v2/04-app-center-and-claw-surfaces.md @@ -0,0 +1,242 @@ +# Plugin v2 App Center 与 Claw Surface + +状态:`proposed` + +## 设计原则 + +复刻 Codex 的信息架构和状态语义,不复刻其视觉主题。Lime 继续使用当前浅色、安静、桌面应用感的设计语言,不引入 Codex/ChatGPT 的深色背景、品牌渐变或超大圆角。 + +## 页面类型 + +App Center 是目录型列表工作台,不是营销 landing page,也不是发布后台。 + +主对象:插件包。 + +当前阶段:发现、检查、安装、启停或更新。 + +主操作:根据状态显示一个明确动作;低频动作进入更多菜单或详情页。 + +## 页面结构 + +```text +┌────────────────────────────────────────────────────────────┐ +│ Plugins [搜索] [来源] [刷新] │ +├──────────────┬─────────────────────────────────────────────┤ +│ 全部 │ 已安装 3 / 可用 18 │ +│ 已安装 │ │ +│ 官方 │ 插件列表 │ +│ Workspace │ icon 名称 / 描述 / 来源 / 状态 [主动作] │ +│ Repo / Local │ icon 名称 / 描述 / 来源 / 状态 [主动作] │ +│ 自定义来源 │ │ +│ + 添加来源 │ │ +└──────────────┴─────────────────────────────────────────────┘ +``` + +宽窗口使用左侧来源 rail + 列表;窄窗口把来源折叠为下拉菜单。不要把每个来源做成等权大卡片。 + +## 顶部工具区 + +- 标题与安装摘要保持紧凑。 +- 搜索框支持名称、描述、keyword、Skill、MCP server。 +- 来源筛选优先使用下拉或左侧 rail。 +- 刷新使用图标按钮。 +- 添加 marketplace 是次级动作,不与安装主动作竞争。 +- 不显示大段“插件是什么”说明;帮助信息进入 tooltip/help panel。 + +## 列表项 + +每项固定展示: + +- 40px 左右 logo;无 logo 时使用稳定生成的 fallback,不复用同一默认图标。 +- display name。 +- 一行 short description。 +- source label。 +- installed/enabled/admin/auth/update 中最关键的一个状态。 +- 单一主动作。 +- 更多菜单。 + +状态与动作映射: + +| 状态 | 主动作 | 次级动作 | +| ----------------- | -------------- | ---------------------- | +| available | 安装 | 查看详情 | +| installing | 安装中 | 取消,仅在可安全取消时 | +| installedEnabled | 在 Claw 中使用 | 禁用、卸载、来源 | +| installedDisabled | 启用 | 卸载、来源 | +| updateAvailable | 更新 | 在 Claw 中使用、卸载 | +| authRequired | 连接 | 禁用、卸载 | +| disabledByAdmin | 无 | 查看策略说明 | +| failed | 重试 | 查看错误、卸载 | + +卡片/行内不同时出现“打开、安装、更新、发布、审核、删除”五六个按钮。 + +## 来源导航 + +与 Codex 对齐的来源分区: + +- 全部插件 +- 已安装 +- Lime Bundled/Official +- Workspace +- Shared with me +- Repo marketplace +- Personal/Local +- 用户配置 marketplace +- 添加 marketplace + +空来源不必强制显示;Workspace 可在远端加载中显示稳定 skeleton,失败时只影响该来源,不清空其他 marketplace。 + +## 搜索 + +- 输入即时过滤当前 projection,不重新触发安装状态读取。 +- 搜索为空时展示目录;无结果时显示当前 query 和清空入口。 +- source tab 与 query 同时生效。 +- 搜索结果仍展示 source 和 policy,不能因过滤丢失安全语境。 + +## 详情页/抽屉 + +详情不是卡片里的嵌套卡片。使用独立详情视图或右侧详情 pane: + +```text +Header: logo / name / developer / version / primary action +Summary: long description / source / policy / auth +Capabilities: Skills / MCP Apps / Hooks +Examples: default prompts +Trust: website / privacy / terms / digest or signature +Diagnostics: only when blocked or failed +``` + +详情中的能力列表可以折叠,但外部写入、Hook 事件和授权要求默认可见。 + +## 安装确认 + +确认弹窗只在真正安装前出现,内容聚焦影响: + +- 来源与开发者 +- Read/Write/Interactive 能力 +- Skills、MCP/apps、Hooks 数量 +- auth-on-install/on-use +- 隐私和条款链接 + +按钮:`取消`、`安装`。不要把“安装并运行、安装并打开、安装并授权”混成多个竞争按钮;安装后按 auth policy 进入下一状态。 + +## Claw Composer + +### `@` Picker + +Picker 分组: + +- 已安装插件 +- 可安装建议 +- 其他现有 mention 类型 + +每个插件项展示 logo、名称、简述和状态。已安装插件选中后插入结构化 mention;可安装项选中后打开安装确认,不直接插入可执行 mention。 + +### mention 显示 + +显示:`@Acme Projects` + +结构化值: + +```text +kind: plugin +pluginId +sourceIdentity +displayName +version? +``` + +发送时不得退化成只靠文本正则重新识别。 + +### 新 thread 提示 + +安装、启停或更新后,如果当前 thread 的 runtime snapshot 已建立,显示简短提示:“插件将在新对话中生效”。提供“新建对话”动作,不静默修改当前 turn 的 tool schema。 + +## Claw Right Surface + +### 支持 surface + +- MCP/App UI:结构化查看、比较、编辑、确认。 +- Browser:复用 Browser Right Surface。 +- Structured result:表格、列表、详情或 diff。 +- File/media preview:复用现有 workspace preview。 + +### 布局 + +- Right Surface 与 timeline 保持同一 thread/turn context。 +- 顶栏显示插件名、当前工具/资源名、刷新/关闭等明确命令。 +- 主内容全高、可滚动,不放在装饰性外层卡片中。 +- 窄窗口改为可切换主视图,不压缩到不可读的双栏。 +- UI resource 自身不覆盖 Lime 全局标题栏和导航。 + +### 生命周期 + +- 打开 surface 不等于重新调用 tool。 +- 刷新需要新的 tool/resource read 时,走标准 MCP 调用并记录 item。 +- 用户提交写操作时走 approval/elicitation。 +- thread 恢复后从 item/read model 恢复 surface descriptor。 +- 插件卸载后历史 item 保留只读结果;不能再发起新动作。 + +## 视觉规则 + +- 主题继续使用 Lime 当前 color tokens。 +- 卡片圆角不超过现有应用中心规范;列表优先,避免大块营销卡。 +- 一个插件只使用自己的 logo/brand color 做局部识别,不染色整页。 +- 状态色:成功 emerald、信息 sky/slate、提醒 amber、错误 rose/red。 +- 主动作使用稳定按钮宽度,异步状态不引发布局跳动。 +- 不使用大面积渐变、半透明主表面、嵌套卡片或说明型 hero。 + +## 响应式约束 + +至少验证: + +- 1024x768:来源可折叠,列表名称与主动作不重叠。 +- 1280x800:标准 rail + 列表 + 可选详情 pane。 +- 1440x900:提高信息密度,不放大字体。 +- 390x844:来源变下拉,详情占主视图,按钮文案可换行但不溢出。 + +稳定尺寸: + +- logo/icon 固定方形。 +- 工具按钮固定 `h/w`。 +- 状态列与主动作有 min/max width。 +- 长名称和长 source label 使用 truncate + title/tooltip。 + +## 可访问性与国际化 + +- 所有 icon button 有 `aria-label` 与 `title`。 +- tab/segmented control 使用正确 selected state。 +- 安装、启停使用 `aria-busy`/disabled 状态并保留可读原因。 +- 键盘可完成搜索、切换来源、打开详情、安装和返回。 +- 用户文案覆盖 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR`。 +- plugin manifest 内容按原文展示;系统状态和动作本地化。 + +## 稳定 DOM 合同 + +建议 test id: + +```text +plugin-directory +plugin-source-selector +plugin-search-input +plugin-list +plugin-list-item +plugin-primary-action +plugin-detail +plugin-install-dialog +plugin-mention-option +plugin-right-surface +``` + +测试使用 identity 属性,例如 `data-plugin-id`、`data-source-kind`、`data-installed`,不依赖样式 class 或中文文本定位关键对象。 + +## UI 退出条件 + +- [ ] 用户 5 秒内能看出当前来源、安装状态和下一步动作 +- [ ] installed/auth/admin/update 状态不互相覆盖 +- [ ] App Center 与 Claw 显示同一 plugin identity 和版本 +- [ ] `@plugin` 可安装建议不会伪装为已可用能力 +- [ ] Right Surface 从 Thread/Turn/Item 恢复 +- [ ] 1024 与移动宽度无文字/按钮重叠 +- [ ] 五语言和键盘操作有稳定回归 +- [ ] 真实 Electron Gate B 覆盖安装、mention、tool call 与 surface diff --git a/internal/roadmap/plugin/v2/05-migration-and-cleanup.md b/internal/roadmap/plugin/v2/05-migration-and-cleanup.md new file mode 100644 index 000000000..9941c4a0d --- /dev/null +++ b/internal/roadmap/plugin/v2/05-migration-and-cleanup.md @@ -0,0 +1,170 @@ +# Plugin v2 迁移与清理账本 + +状态:`historical-docs-frozen / implementation-migration-pending` + +更新时间:2026-08-04 + +## 目标 + +本文件定义 Plugin v2 从旧包标准、旧前端 registry 和插件专用 worker 迁入 current 主链的唯一清理顺序。它是删除账本,不是兼容层设计。 + +迁移完成后的唯一产品链为: + +```text +.codex-plugin/plugin.json / marketplace.json + -> App Server plugin domain + -> Skills / MCP / Hooks + -> RuntimeCore / Tool Runtime + -> Thread / Turn / Item projection + -> App Center / Claw / Right Surface +``` + +## 清理原则 + +1. 先建立 current owner 和回归,再迁移调用,最后删除旧 owner。 +2. 不增加 `v1 | v2` 双读、manifest 自动转换或 renderer fallback。 +3. 旧代码只有在调用者清零、fixture 已迁移、回流守卫生效后才能删除。 +4. 发布、审核和商业化平台不是 Plugin consumer runtime 的组成部分;需要时进入独立路线图。 +5. 删除动作必须保留可审计证据,并遵循仓库危险操作确认要求。 +6. 重大架构落地时,同一变更集必须更新 `internal/aiprompts/architecture.md` 和对应执行计划。 + +## 分类定义 + +| 分类 | 允许行为 | 禁止行为 | +| ---------------------- | ------------------------------ | --------------------------------------------- | +| `current` | 扩展、修复、补测试 | 绕过 owner 建第二套实现 | +| `deprecated` | 迁出调用、补观测、为删除做准备 | 新增能力、新增调用者、作为 fallback | +| `dead` | 删除、加禁止回流守卫 | 恢复、包装、继续维护 | +| `historical reference` | 查阅、引用、向 v2 提炼有效决策 | 直接作为 current 实现或验收依据、独立继续演进 | +| `historical evidence` | 查阅和复核历史结果 | 冒充当前 Gate B 证据、覆盖 current 结果 | + +## Current:保留并对齐 + +| 能力 | Current owner | v2 动作 | +| ---------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| Plugin protocol | `lime-rs/crates/app-server-protocol`、App Server request processor | 增加 Codex-compatible marketplace/plugin 方法与 projection | +| Plugin domain | `lime-rs/crates/app-server` 内聚后的 plugin owner | 拆分 discovery、manifest、install store、activation,避免继续堆入单文件 | +| Skills | 现有 Skills discovery/loader owner | 按 plugin identity 和 source authority 装配 | +| MCP servers/apps | 现有 MCP owner | 复用 server lifecycle、auth、tool/resource 和 App UI 协议 | +| Hooks | RuntimeCore / tool runtime 对应 lifecycle owner | 只接受 manifest 声明并进入统一权限与事件链 | +| Agent execution | `agent-runtime`、RuntimeCore、`tool-runtime` | Plugin 仅贡献能力,不拥有 agent loop | +| Read model | App Server、`thread-store` 与 projection package | 记录 plugin identity、tool item、surface descriptor 和恢复状态 | +| Desktop bridge | Electron preload/IPC 到 `app_server_handle_json_lines` | 只转发 current JSON-RPC,不承接 catalog 和业务状态 | +| App Center | `src/features/plugin/ui/**` 的 current 页面 owner | 改为消费 App Server projection,不再合并 registry | +| Claw | composer、streaming、thread/read model owner | 增加结构化 `@plugin` mention 与调用状态 | +| Right Surface | `RightSurfaceRegistry` 及 surface owner | 承载 MCP/App UI、Browser、结构化结果和文件预览 | + +## Deprecated:只迁出 + +以下区域在 v2 迁移期间可以被读取和测试,但不得新增产品能力: + +| 旧事实或实现 | 当前问题 | 迁出目标 | +| -------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------- | +| 根目录 `plugin.json` 与 `lime.plugin.package.v1` | 与 Codex 包标准分叉 | `.codex-plugin/plugin.json` | +| `contributions.runtime` / `contributions.workbench` | 把 agent runtime 和 UI host 私有化 | Skills、MCP servers/apps、Hooks、Right Surface descriptor | +| `app.runtime.yaml` 主入口 | 引入插件专用 worker 生命周期 | RuntimeCore/MCP/Skill current activation | +| `plugin_packages/plugin_manifest.rs` 旧 projection | 解析、兼容、UI/runtime 推断混在一起且超过文件治理阈值 | 小职责 manifest parser 与 protocol projection | +| `marketplaceRegistryLoader.ts` | Renderer 合并 marketplace、installed、manifest、readiness | App Server `plugin/list` 与 `plugin/installed` | +| `src/features/plugin/manifest/**` 旧解析链 | Renderer 直接理解安装包标准 | Renderer 只使用 protocol-generated types | +| `src/features/plugin/install/**` 中本地事实拼装 | installed/setup/cache 多源并存 | App Server installed store 与原子安装事务 | +| `src/features/plugin/runtime/**` 私有 runtime/projection | 与 RuntimeCore、MCP、read model 重叠 | current runtime 与统一 item projection | +| `pluginUiRuntimeStart/Status/Stop` 命令族 | 第二套 UI runtime 生命周期 | MCP/App UI resource 与 Right Surface lifecycle | +| `src/features/plugin/publish/**` | 发布后台混入插件消费主线 | 独立发布平台路线图或删除 | +| `src/features/plugin/packaging/**` | 自定义桌面应用打包与 Plugin 包混为一体 | 只保留 Codex-compatible 包校验所需能力 | + +`deprecated` 不等于立即整目录删除。每个子域必须先完成调用图、测试与数据所有权核对,再按最小写集迁出。 + +## Dead:迁移后删除 + +满足对应退出条件后,下列路径或语义应删除: + +| Dead surface | 候选路径 | 删除前置条件 | +| --------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| Electron plugin worker | `electron/pluginRuntimeTaskHost.ts`、`electron/pluginTaskWorker.ts` | 所有执行已进入 App Server/RuntimeCore;IPC 调用清零 | +| App Server plugin worker | `lime-rs/crates/app-server/src/runtime/plugin_worker_*`、`plugin_task_runtime.rs` | runtime tests 与 read model fixture 已迁移到 current tool/MCP item | +| 独立 Plugin UI host | `src/features/plugin/runtime/uiExtensionHost.ts` 及私有 bridge | MCP/App UI 可在 Right Surface 完成同等用户流程 | +| Renderer capability mock fallback | `src/features/plugin/sdk/MockCapabilityHost.ts` 等生产可达 mock | mock 仅存在于测试夹具且构建守卫证明生产不可达 | +| 旧 package fixtures | `src/features/plugin/testing/fixtures/**/plugin.json`、`app.runtime.yaml` | fixture 已换成 `.codex-plugin/plugin.json` 与 MCP/Skill/Hook 样例 | +| 旧技术标准 | `internal/tech/plugin/lime-plugin-package-v1.md` | v2 contract 已成为唯一文档入口,入站引用清零 | +| 旧 smoke scripts | `smoke:plugin-ui-runtime-*`、`smoke:plugin-runtime-*` 对应脚本 | Gate B current plugin fixture 覆盖安装到 Right Surface 闭环 | + +不要用空壳 facade 保留旧命令名。调用者迁完后直接删除命令、类型、fixture 和脚本入口。 + +## 冻结保留的历史文档 + +以下文档保留在仓库中,但冻结为 `historical reference` 或 `historical evidence`: + +| 分类 | 文件 | 保留价值 | +| ------------ | -------------------------------------------------------------------------------------------- | ---------------------------------- | +| 历史设计参考 | `prd.md`、`architecture.md`、`interface-contracts.md`、`technical-baseline.md` | 产品需求、分层、接口和旧实现约束 | +| 历史体验参考 | `prototype.md`、`prototype.html`、`user-operations-guide.md`、`history-product-workspace.md` | 页面结构、用户流程、恢复和运维体验 | +| 历史跟踪 | `implementation-plan.md` | 已实施切片、问题背景和验证记录 | +| 历史证据 | `e2e-evidence.md`、`evidence/plugin-productization-e2e-summary.json` | 旧链路的 E2E 结果与局限 | +| 独立范围输入 | `deverlop/plugin-publish-center-prd.md`、`deverlop/plugin-publish-limecore-server-plan.md` | 发布平台需求,供未来独立路线图提炼 | + +只有 `internal/roadmap/plugin/README.md` 与 `internal/roadmap/plugin/v2/**` 可以继续演进 Plugin current 路线。历史文档若发现仍有效的决策,应在 v2 对应文档中重述并记录当前依据,而不是直接恢复其事实源地位。 + +## 入站引用迁移 + +已知需要同步的 current 入站引用: + +| 来源 | 动作 | +| ------------------------------------------- | --------------------------------------------------------------------------------- | +| `internal/roadmap/browser/README.md` | 将旧 PRD、architecture、contracts、baseline 链接改到 v2 产品、架构和 surface 合同 | +| `internal/roadmap/agentworkbench/README.md` | 保持指向根 `plugin/README.md`,由根索引导航 v2 | +| `internal/roadmap/zuanjia/README.md` | 保持指向根 `plugin/README.md`,不复制 Plugin 规则 | +| `internal/tech/plugin/README.md` | v2 实施时改为 deprecated 声明,最终删除旧标准入口 | + +历史执行日志中出现旧路径不需要机械重写;如果文档仍被 `docs:boundary` 当作 current 输入,则应由边界规则显式排除历史记录,而不是伪造过去的证据。 + +## 数据与状态迁移 + +本仓库没有外部用户和兼容负担,因此不做长期格式迁移器。开发态本地数据按以下策略处理: + +1. v2 installed store 使用新的 schema/version 和独立目录。 +2. 首次启动发现旧 installed/setup/cache 时,只显示“一次性清理旧插件数据”诊断,不自动转换为已安装 v2 插件。 +3. bundled 插件由当前 marketplace 重新解析与安装,不继承旧 readiness。 +4. connector 凭证由既有 auth owner 管理;删除插件只解除引用,不擅自删除共享凭证。 +5. 删除旧缓存前列出 namespace、预计体积和恢复边界,并走用户确认。 +6. thread 历史保留旧 tool/item 文本投影,但不恢复已删除 worker;恢复时明确显示历史能力不可重跑。 + +## 回流守卫 + +V2-0 和 V2-6 至少建立以下静态守卫: + +```text +forbidden manifest: schemaVersion = lime.plugin.package.v1 +forbidden fields: contributions.runtime, contributions.workbench +forbidden entry: app.runtime.yaml +forbidden production imports: MockCapabilityHost, mockCapabilityProfile +forbidden commands: pluginUiRuntimeStart, pluginUiRuntimeStatus, pluginUiRuntimeStop +forbidden renderer behavior: fs/path scan of plugin package or marketplace +``` + +守卫应扫描生产代码、package scripts、协议 catalog 和 current 文档;测试 fixture 例外必须精确到文件,不能按目录放行。 + +## 单切替换顺序 + +```text +冻结旧标准并加“禁止新增”守卫 + -> 建立 v2 parser/protocol/store + -> 迁移 Skills/MCP/Hooks activation + -> 迁移 App Center 与 @plugin + -> 迁移 Right Surface 与历史投影 + -> 迁移 Gate B fixture + -> 清零旧调用和旧命令 + -> 删除 worker、旧 manifest、旧脚本并冻结历史文档 + -> 加“禁止恢复”守卫 +``` + +任一步出现主链缺口都应修复 current owner,不允许临时回落到旧 worker 或 renderer registry。 + +## 删除完成条件 + +- `rg` 无生产代码引用旧 manifest、旧 worker 命令和旧 registry loader。 +- package scripts 不再暴露旧 runtime smoke。 +- protocol/client/catalog/fixture 中不存在旧命令。 +- App Center 与 Claw 只消费 App Server v2 projection。 +- Gate B current fixture 覆盖安装、调用、Right Surface、恢复、卸载。 +- `governance:legacy-report` 将旧能力标记为 `dead/deleted/forbidden-to-restore`。 +- 根 Plugin README 以 v2 为 current 导航,并将旧文档明确分区为历史参考。 diff --git a/internal/roadmap/plugin/v2/06-implementation-plan.md b/internal/roadmap/plugin/v2/06-implementation-plan.md new file mode 100644 index 000000000..5b9a58cea --- /dev/null +++ b/internal/roadmap/plugin/v2/06-implementation-plan.md @@ -0,0 +1,245 @@ +# Plugin v2 实施计划 + +状态:`active / P1-P5 core implemented` + +更新时间:2026-08-05 + +## 主目标 + +以最短可验证路径建立 Codex-compatible Plugin consumer runtime,并在同一主链内完成 App Center、Claw `@plugin` 和 Right Surface。实施期间不维持 v1/v2 产品双轨。 + +## 写集纪律 + +每个阶段开始前,在 `internal/exec-plans/` 建立或更新执行计划,声明: + +- 本阶段目标和退出条件 +- 精确写集与脏热区避让 +- current/deprecated/dead 变化 +- 架构图确认 +- 定向测试与 Gate B 证据路径 +- 阻塞和未验证原因 + +并行开发时,protocol、App Server plugin domain、RuntimeCore、App Center、Claw/Right Surface 五个写集不得由不同变更同时重写同一事实。 + +## 阶段总览 + +| 阶段 | 主结果 | 前置 | 完成信号 | +| ---- | ----------------------------- | ----------- | ----------------------------------------------------- | +| V2-0 | 冻结标准与回流守卫 | v2 文档确认 | 旧标准禁止新增,执行计划落盘 | +| V2-1 | Manifest/marketplace/protocol | V2-0 | App Server 可列出和读取 bundled/repo/personal 插件 | +| V2-2 | 原子安装与 installed store | V2-1 | install/update/uninstall 可恢复且无半安装态 | +| V2-3 | RuntimeCore activation | V2-2 | Skills/MCP/Hooks 在新 thread 可追踪装配 | +| V2-4 | App Center | V2-1、V2-2 | 页面只消费 App Server projection | +| V2-5 | Claw 与 Right Surface | V2-3 | `@plugin` 到 tool item/UI surface 闭环 | +| V2-6 | 旧实现删除与文档冻结 | V2-4、V2-5 | 旧 worker、manifest、registry、命令清零,历史文档冻结 | +| V2-7 | 跨平台与 release gate | V2-6 | macOS/Windows 与 Gate B 全通过 | + +## 当前实施快照 + +| 阶段 | 状态 | 2026-08-05 事实 | +| ---- | ------- | ------------------------------------------------------------------------------------------------- | +| V2-0 | DONE | v2 文档、唯一 manifest 与 current owner 已冻结 | +| V2-1 | DONE | typed catalog/read/install/installed/enabled protocol 已接入 | +| V2-2 | PARTIAL | staging、digest、幂等与原子替换已接入;崩溃恢复/残留清理仍待补 | +| V2-3 | PARTIAL | Plugin Skills 与 MCP 已装配;独立 Apps/Hooks 仍待补 | +| V2-4 | PARTIAL | App Center 已只读 typed catalog;真实安装点击与窄窗证据仍待补 | +| V2-5 | PARTIAL | `plugin://`、MCP App Right Surface 与 reload 恢复已接入;picker 点击、Browser/file surface 仍待补 | +| V2-6 | PENDING | 未物理删除旧实现;删除前必须再次取得用户确认 | +| V2-7 | PARTIAL | macOS core Gate B 已通过;Windows 与完整 P0/release matrix 未通过 | + +当前完成度为 `75%`。当前主目标已从“建立 parser/protocol”转为“补齐真实用户点击、卸载历史、cold restore 与跨平台证据”。 + +## V2-0:冻结旧标准 + +### 写集 + +- `internal/roadmap/plugin/v2/**` +- `internal/roadmap/plugin/README.md` +- `internal/exec-plans/.md` +- 既有 governance 检查的最小扩展点 + +### 工作项 + +1. 确认 `.codex-plugin/plugin.json` 是唯一 manifest。 +2. 确认 App Server 是 catalog、installed、enabled、policy、auth readiness 唯一事实源。 +3. 把旧 `lime.plugin.package.v1` 和 worker 命令列为 deprecated。 +4. 增加“禁止新增旧字段/新调用者”的窄范围守卫。 +5. 记录架构图确认,标出 current owner 与待删除 owner。 + +### 退出条件 + +- v2 系列文档通过格式和链接检查。 +- 旧文档不再被根 README 作为 current 导航。 +- 新代码无法继续引入旧 manifest 字段或生产 mock。 + +## V2-1:Manifest、Marketplace 与 Protocol + +### 建议 owner + +```text +lime-rs/crates/app-server/src/plugins/ + manifest.rs + marketplace.rs + resolver.rs + projection.rs + +lime-rs/crates/app-server-protocol/ + plugin v2 request/response types +``` + +最终目录以现有 crate 边界为准;不要新建与既有 owner 重叠的 crate。 + +### 工作项 + +1. 解析 `.codex-plugin/plugin.json`,校验 identity、version、relative paths 和 capability declarations。 +2. 解析 repo/personal marketplace,并支持 bundled/configured/remote source descriptor。 +3. 保留 source authority,禁止把远端路径当本地路径。 +4. 实现 `marketplace/add|remove|upgrade` 与 `plugin/list|installed|read` current protocol。 +5. 返回完整 `PluginSummary` / `PluginDetail`,包含 installed、enabled、policy、auth、availability 和版本字段。 +6. 生成或共享 protocol types,Renderer 不手写镜像类型。 + +### 退出条件 + +- bundled、repo、personal 三类来源可被同一 list API 表达。 +- 非法 manifest、路径越界、重复 ID、冲突版本返回稳定错误码。 +- Renderer 没有参与目录扫描或 manifest 解析。 +- `npm run test:contracts` 通过。 + +## V2-2:安装 Store 与事务 + +### 工作项 + +1. 定义 installed record、source lock、content digest、enabled state 和 schema version。 +2. 下载或复制到 staging,完成大小、路径、digest、manifest 和 policy 校验后原子切换。 +3. 实现 `plugin/install`、`plugin/uninstall` 和 update flow。 +4. 安装与 connector auth 分离;`ON_INSTALL` 只触发授权流程,不把授权写成安装成功的必要伪状态。 +5. 卸载前关闭能力装配,清理包数据,保留共享凭证和 thread 历史。 +6. 崩溃恢复时回滚 staging 或完成已提交事务,不留悬空状态。 + +### 退出条件 + +- 安装中断、磁盘不足、digest 不一致和重复请求均有确定结果。 +- update 失败保留上一可用版本。 +- uninstall 后 package、index、activation projection 无残留。 +- macOS 与 Windows 路径测试通过。 + +## V2-3:Skills、MCP 与 Hooks 激活 + +### 工作项 + +1. 将 installed+enabled 插件解析为 inert capability descriptors。 +2. 新 thread 创建或明确 reload 时生成 activation snapshot。 +3. Skills 进入现有 skill discovery;MCP servers/apps 进入现有 MCP lifecycle;Hooks 进入统一事件与权限 owner。 +4. 为每个 capability 携带 plugin ID、marketplace、version、source authority 和 digest。 +5. tool call、hook invocation、auth challenge 和失败事件进入 Thread/Turn/Item read model。 +6. enable/disable 不篡改运行中的 turn;新状态从下一个规定边界生效。 + +### 退出条件 + +- 不显式 mention 时,Skill/MCP 仍可按描述被正常发现。 +- 显式 mention 时只收窄插件上下文,不绕过权限。 +- 插件 MCP 启停与普通 MCP 共用 lifecycle 和诊断。 +- 运行路径不进入 plugin worker。 + +## V2-4:App Center 重做 + +### 写集 + +- `src/features/plugin/ui/**` +- App Server plugin gateway/client +- 五语言资源与定向测试 + +### 工作项 + +1. 页面改为 `All / Installed / source marketplace` 信息架构。 +2. 增加搜索、来源筛选、详情、安装、启停、更新、卸载和 auth 入口。 +3. 详情披露 Skills、Hooks、Apps、MCP servers、source、auth、privacy、terms。 +4. 保持 Lime 当前主题、密度、颜色、圆角和 icon 语言,只复刻 Codex 的产品结构与状态语义。 +5. 通过按钮或下拉收纳低频动作;不恢复发布后台和独立 runtime page。 +6. 删除 renderer registry 合并、manifest parsing 和 mock fallback。 + +### 退出条件 + +- App Center 只调用 current plugin gateway。 +- 加载、空、错误、安装中、管理员禁用、待授权、可更新状态完整。 +- `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 文案齐全。 +- 桌面与窄窗口无溢出、重叠或布局变形。 + +## V2-5:Claw Mention 与 Right Surface + +### 工作项 + +1. `@` picker 接入 installed projection,并把可安装建议与可调用插件明确区分。 +2. composer 写入结构化 `plugin://` mention,不依赖显示名反解析。 +3. turn request、RuntimeCore trace、tool item 和历史恢复贯穿同一 plugin identity。 +4. MCP/App UI resource 注册到 Right Surface;Browser intent 复用 browser tab;文件和结构化结果复用既有 surface。 +5. 后台结果进入 pending badge,不抢用户当前右侧 tab。 +6. surface action 回流 App Server current action/turn contract,不直连 provider、文件系统或插件 worker。 + +### 退出条件 + +- `@plugin` 可从 composer 触发真实 MCP/Skill 调用。 +- Right Surface 显示与当前 turn/tool item 对应的真实结果。 +- 关闭、重开和恢复 thread 后 identity 与 surface state 一致。 +- 插件 UI 不启动任意 iframe、本地 server 或私有 worker bridge。 + +## V2-6:旧路删除 + +### 工作项 + +1. 生成旧 manifest、命令、import、script 和 fixture 调用图。 +2. 迁移最后调用者和测试。 +3. 删除 Electron/App Server plugin worker、plugin UI runtime、renderer registry 和旧包 parser。 +4. 删除旧 smoke scripts、package scripts 和 fixture。 +5. 将 v1 技术标准与旧路线图冻结为历史参考,current 导航只进入 v2。 +6. 将“禁止新增”守卫升级为“禁止恢复”守卫。 + +### 退出条件 + +- [05-migration-and-cleanup.md](./05-migration-and-cleanup.md) 的 dead 清单全部满足前置条件。 +- `rg`、protocol catalog、package scripts 和 build graph 均无旧入口。 +- `governance:legacy-report` 无 current/compat 误判。 +- current Gate B fixture 在删除后仍通过。 + +删除属于高风险操作,执行前必须单独列出精确文件并取得明确确认。 + +## V2-7:跨平台与 Release Gate + +### 工作项 + +1. 在 macOS 验证 bundled、repo、本地安装、auth、调用、UI、更新和卸载。 +2. 在 Windows 验证路径、权限、进程、归档和原子替换语义。 +3. 验证应用升级后的 bundled marketplace upgrade 与 user-installed 保留策略。 +4. 完成恶意包、损坏包、管理员禁用和网络中断测试。 +5. 运行完整 Gate B 并记录证据摘要。 + +### 退出条件 + +- [07-verification-contract.md](./07-verification-contract.md) 的 P0 场景全部通过。 +- `npm run verify:local` 通过。 +- manifest/workspace/Electron 版本变化时 `npm run verify:app-version` 通过。 +- release evidence 可追踪 plugin/thread/turn/item/surface identity。 + +## 测试扩展策略 + +| 风险 | 最小验证 | 扩展条件 | +| ------------------ | -------------------------------------- | --------------------------------------------- | +| 文档/守卫 | 格式、链接、`git diff --check` | 影响 docs boundary 时跑 contracts | +| Parser/protocol | Rust 单元、schema fixture、contracts | 影响多 crate 时跑 related tests | +| Install store | 单元、崩溃恢复、路径安全 | 涉及 OS 行为时双平台 | +| Runtime activation | current fixture、MCP/Skill integration | 影响 Thread/Turn/Item 时扩大 read model tests | +| GUI | component tests、GUI smoke | 影响 bridge/Right Surface 时必须 Gate B | +| 清理 | 旧引用扫描、contracts、current fixture | 删除共享 owner 时跑 verify:local | + +## 不允许的实施捷径 + +- 先在前端写一份 v2 marketplace 假数据等待后端补齐。 +- 用 feature flag 长期保留 v1/v2 两套 production path。 +- 把 Codex manifest 转换成 `lime.plugin.package.v1` 后继续走旧 worker。 +- 以“兼容 Desktop”为由在 Electron main process 重建业务状态。 +- 以 UI demo 代替真实 App Server/RuntimeCore 调用证据。 +- 将发布中心、审核后台或商业化能力塞回 P0 consumer runtime。 + +## 完成度口径 + +阶段完成度只按退出条件计算,不按代码量或页面可见程度计算。任何阶段存在 production mock、legacy command hit、身份断链或未记录的跨 owner fallback,完成度不得超过 `90%`,也不得进入 release evidence。 diff --git a/internal/roadmap/plugin/v2/07-verification-contract.md b/internal/roadmap/plugin/v2/07-verification-contract.md new file mode 100644 index 000000000..fc1216ed0 --- /dev/null +++ b/internal/roadmap/plugin/v2/07-verification-contract.md @@ -0,0 +1,316 @@ +# Plugin v2 验收合同 + +状态:`active / macOS P0 core Gate B passed / release pending` + +更新时间:2026-08-05 + +## 验收目标 + +证明 Plugin v2 不是静态目录页或 renderer fixture,而是从真实安装来源经过 Electron/App Server/RuntimeCore 到 Claw 用户可见状态的生产主链。 + +Gate A 用于快速验证浏览器投影;Gate B 才是功能验收。任何 P0 场景只有 Gate A、组件测试或 mock 证据时,一律记为未通过。 + +## 质量层级 + +| 层级 | 证明内容 | 能否替代 Gate B | +| ----------- | ------------------------------------------------------------------------------ | --------------- | +| Unit | parser、state reducer、transaction、projection 的局部行为 | 否 | +| Contract | protocol、Electron IPC、preload、gateway、catalog 一致 | 否 | +| Integration | App Server 与 filesystem/MCP/RuntimeCore 协作 | 否 | +| Gate A | 浏览器或静态 fixture 的页面信息架构与布局 | 否 | +| Gate B | 真实 Electron、真实 bridge、真实 App Server、真实 runtime/read model 和可见 UI | 是 | +| Release | Gate B 加跨平台、升级、恶意输入和残留检查 | 是,最终门禁 | + +## Gate B 必须穿过的链路 + +```text +Electron Desktop Host + -> preload / IPC + -> app_server_handle_json_lines + -> App Server JSON-RPC + -> plugin domain / installed store + -> RuntimeCore / MCP / Skills / Hooks + -> Thread / Turn / Item read model + -> renderer gateway + -> App Center / Claw / Right Surface +``` + +证据必须能关联: + +```text +pluginId + marketplaceId + version/digest +threadId + turnId + itemId/toolCallId +surfaceId/resourceUri +``` + +若任一层使用 renderer mock、测试专用 bridge、旧 plugin worker 或手工注入 read model,则该次运行不能标记 Gate B。 + +## 当前 Gate B 证据 + +2026-08-05 已 fresh 通过单一 Plugin v2 Electron fixture: + +```bash +npm run smoke:plugin-v2-current-electron-fixture +``` + +该 fixture 强制从源码重建 App Server sidecar,并从真实 GUI 实际经过: + +```text +App Center sidebar + -> plugin/list / bundled Browser identity + -> window.electronAPI.dialog.open + -> app:dialog:open / Electron dialog.showOpenDialog + -> install review / confirm + -> plugin/install + -> plugin/read + -> plugin/installed + -> App Center menu disable / enable + -> plugin/enabled/set(false / true) + -> Claw model selector + -> Plus / Plugins / installed Plugin option + -> plugin://mcp-elicitation-plugin structured mention + -> thread/start + -> GUI send / turn/start + -> McpThreadRuntime / McpClientManager + -> mcp__plugin__mcp-elicitation-plugin__demo__release_check + -> mcpServer/elicitation/request + -> Renderer 表单提交 + -> provider final + -> thread/read + -> mcpResource/read + -> MCP App Right Surface / WebContentsView + -> Renderer reload restore + -> App Center uninstall confirm + -> plugin/uninstall / plugin/installed cleared + -> reopen original Thread + -> historical mention / tool item / Right Surface unavailable state +``` + +自动断言结果: + +```text +proofLevel=Gate B +backendMode=runtime +electronPreloadBridge=true +appServerHandleJsonLinesSeen=true +appCenterBundledListVisible=true +appCenterBundledIdentityStable=true +nativeDirectoryDialogObserved=true +appCenterInstallReviewVisible=true +appCenterInstallCompleted=true +appCenterEnableToggleCompleted=true +clawFixtureModelSelected=true +clawPluginPickerVisible=true +clawPluginBadgeVisible=true +clawStructuredMentionObserved=true +clawThreadTurnItemIdentityStable=true +rendererFormVisible=true +rendererConfirmedSubmitted=true +dynamicToolCanonicalCompleted=true +runtimeInitializeProtocolVersion=2025-06-18 +missingRequiredMethods=[] +legacyMcpCommandsSeen=[] +pluginWorkerHitCount=0 +productionMockFallbackHitCount=0 +providerFinalTextObserved=true +mcpLedgerAccepted=true +mcpAppRightSurfaceVisible=true +mcpAppResourceReadCount=4 +mcpAppHtmlLoadCount=4 +mcpAppToolCallCount=1 +mcpAppRestoredAfterReload=true +mcpAppHistoryUnavailableAfterUninstall=true +mcpAppCanonicalIdentityStable=true +uninstallViaAppCenterCompleted=true +installedProjectionClearedAfterUninstall=true +historyReadableAfterUninstall=true +historyMcpRuntimeNotRestartedAfterUninstall=true +historyProviderNotReexecutedAfterUninstall=true +historyToolNotReexecutedAfterUninstall=true +consoleErrors=[] +``` + +证据位于 `.lime/qc/gui-evidence/plugin-v2-current-electron-fixture/`,包含 summary、脱敏 raw evidence 与 7 张当前 Electron 截图。人工复核确认 App Center、安装 review、Claw picker/mention、Right Surface、cold restore、卸载确认和卸载后历史均非空,无重叠、裁切、变形或主题偏离。 + +summary 记录应用版本、平台/架构、Electron 版本、repository commit、起止时间,以及 `plugin/source/version/digest + session/thread/turn/user item/tool item/tool call/surface/resource` 身份。MCP App resource/HTML 累计计数按“首次打开与显式恢复 2 次、Renderer reload 3 次、跨进程 cold restore 4 次、卸载后保持 4 次”精确断言;MCP tool call 始终仅 1 次。卸载后只恢复 canonical 历史 item 与 surface identity,GUI 明确显示“仅保留历史”,不挂载 WebContentsView,不读取 MCP resource,也不重启 MCP runtime、provider 或 tool。 + +当前 claim boundary 覆盖 bundled 列表、真实 Electron 目录选择与 App Center 安装/启停/卸载、Claw picker 与结构化 mention、manifest 选择的默认 `.mcp.json` 自动启动、真实 MCP tool、elicitation、provider final、canonical tool terminal、`thread/read`、MCP App Right Surface、Renderer reload、跨进程 cold restore 和卸载后历史不可重跑状态。`.mcp.json` 是 MCP 配置文件,唯一 Plugin manifest 仍是 `.codex-plugin/plugin.json`。 + +该证据不证明 repo/personal marketplace、授权、更新、管理员策略、Windows release gate 或五语言窄窗矩阵已通过。 + +## P0 场景矩阵 + +| ID | 场景 | 当前 Gate B | 操作 | 必须观察到的结果 | +| ----- | ------------------------- | ----------- | ------------------------------------------------------------ | ------------------------------------------------------------------------- | +| GB-01 | bundled 列表 | PASS | 启动 Desktop,打开 App Center | `plugin/list` 返回 bundled source;卡片 identity/version 与 manifest 一致 | +| GB-02 | repo/personal marketplace | PENDING | 打开含 repo marketplace 的 workspace,再添加 personal source | 来源分区正确;同 ID 冲突按 authority/policy 给出确定结果 | +| GB-03 | 本地安装 | PASS | 从详情安装未安装插件 | staging 校验、原子提交、installed projection 和按钮状态一致 | +| GB-04 | 安装时授权 | PENDING | 安装 `ON_INSTALL` connector 插件 | 安装与授权状态分开;取消授权不伪装成未安装 | +| GB-05 | 使用时授权 | PENDING | 调用 `ON_USE` 插件 | 首次真实调用时出现授权;授权后原 turn 按合同继续或明确重试 | +| GB-06 | 启用/禁用 | PASS | 禁用已安装插件,创建新 thread,再启用 | 新 thread 不发现禁用能力;启用后的新边界恢复发现 | +| GB-07 | `@plugin` mention | PASS | 在 Claw picker 选择已安装插件并发送 | composer、turn、trace、tool item 使用同一 plugin ID | +| GB-08 | MCP tool call | PASS | 调用插件 MCP 工具 | 真实 MCP lifecycle、权限、流式 tool item 和结果完成 | +| GB-09 | MCP/App UI | PASS | 工具返回 UI resource | Right Surface 打开对应 App UI,surface identity 与 tool item 一致 | +| GB-10 | Browser surface | PENDING | 插件发出 browser intent | 复用 Right Surface browser tab,不弹出第二右栏或系统浏览器 | +| GB-11 | 历史恢复 | PASS | 关闭并恢复 thread | plugin/tool/surface 投影可恢复;不偷偷重跑旧动作 | +| GB-12 | 更新 | PENDING | 安装有新版本的插件 | 新版本校验后原子替换;失败保留旧版本;新 thread 使用新 digest | +| GB-13 | 卸载 | PASS | 卸载已使用插件 | catalog/installed/activation 清理;历史仍可读;共享 auth 不误删 | +| GB-14 | 管理员禁用 | PENDING | 加载 disabled-by-admin 插件 | 可解释但不能启用/调用;UI 不展示无效动作 | + +`PARTIAL` 只表示底层 current contract 或受控 fixture 已覆盖部分步骤,不能替代该行要求的真实用户动作。当前 release gate 仍为未通过。 + +GB-01、03、06、07、08、09、11、13 已通过 macOS 真实 Electron current 链路。其余 P0 场景、Windows 和五语言窄窗矩阵仍阻塞 release。 + +## 安全与鲁棒性场景 + +| ID | 输入/故障 | 预期 | +| ------ | -------------------------------------- | ----------------------------------------------------- | +| SEC-01 | 缺失或非法 `.codex-plugin/plugin.json` | 拒绝解析,错误定位到字段,不写 installed store | +| SEC-02 | `../`、绝对路径、symlink 逃逸 | 拒绝越过 package/source authority | +| SEC-03 | archive bomb、超大文件、文件数爆炸 | 在解包前/中按预算停止并清理 staging | +| SEC-04 | digest/signature 不一致 | 拒绝安装或更新,保留上一可用版本 | +| SEC-05 | 重复 ID、source spoofing | 按 marketplace authority 处理,不静默覆盖 | +| SEC-06 | 安装中断或进程崩溃 | 重启后无半安装态,可重试 | +| SEC-07 | 卸载时文件占用 | 明确残留和重试动作,不谎报成功 | +| SEC-08 | MCP server 启动失败 | installed 保持,readiness 失败可诊断,不回落旧 worker | +| SEC-09 | connector token 失效 | 进入重新授权,不重装插件,不泄漏 token | +| SEC-10 | UI resource 非受信 origin | Host policy 拒绝或隔离,不允许任意本地 bridge | +| SEC-11 | Hook 请求高风险动作 | 进入统一权限/确认,不因插件来源绕过 | +| SEC-12 | 管理员策略运行中变化 | 新请求停止,用户看到策略原因,历史状态保持可读 | + +## UI 验收 + +### App Center + +- All、Installed 和 marketplace 分区可切换,状态不互相污染。 +- 搜索覆盖名称、描述、开发者和能力关键词。 +- 卡片只保留主动作;启停、更新、卸载、授权和来源等低频动作使用详情或菜单。 +- 详情展示 Skills、Hooks、Apps、MCP Servers、source、auth、privacy 和 terms。 +- 使用 Lime 当前主题,不复制 Codex 配色;按钮、图标、间距和状态反馈遵循 Lime design language。 +- 360px 级窄窗口、常规桌面和宽屏下无文字溢出、卡片变形、重叠或横向滚动。 +- 加载、空、错误、离线、安装中、待授权、管理员禁用和更新失败状态均可恢复。 + +### Claw 与 Right Surface + +- `@` picker 中“已安装可调用”和“可安装建议”视觉与行为不同。 +- mention 显示名变化不影响稳定 plugin identity。 +- 后台插件结果只增加 pending badge,不抢当前 tab。 +- MCP/App UI、Browser、文件和结构化结果复用现有 Right Surface registry。 +- 关闭右侧不丢 thread/tool item;恢复时不会自动重复外部动作。 +- surface action 回流 current action/turn contract,网络面板中不存在私有 plugin worker 请求。 + +### 多语言 + +以下 locale 必须同时验收: + +```text +zh-CN +zh-TW +en-US +ja-JP +ko-KR +``` + +至少对最长文案 locale 做窄宽度截图,不能只验证 key 存在。 + +## 协议与身份断言 + +Gate B fixture 应自动断言: + +1. `plugin/list`、`plugin/install`、`plugin/read`、`plugin/installed`、`plugin/uninstall` 请求经过 App Server JSON-RPC。 +2. Desktop 调用经过 preload/IPC 和 `app_server_handle_json_lines`。 +3. Renderer 未直接读取 filesystem、marketplace JSON 或 plugin manifest。 +4. runtime item 包含 plugin identity、capability identity 和 source authority。 +5. UI surface 的 `resourceUri/surfaceId` 可以反查同一 tool item。 +6. thread restore 后 identity 不按 display name 重建。 +7. legacy command hit 为 `0`。 +8. production mock fallback hit 为 `0`。 +9. plugin worker process/IPC hit 为 `0`。 + +## 建议证据包 + +```text +.lime/qc/gui-evidence/plugin-v2-gate-b// + summary.json + bridge-events.jsonl + app-server-requests.jsonl + runtime-items.jsonl + plugin-state-before.json + plugin-state-after.json + screenshots/ + app-center-list.png + plugin-detail.png + claw-mention.png + tool-running.png + right-surface.png + restored-thread.png +``` + +`summary.json` 至少记录:应用版本、平台、commit、scenario、plugin/source/version/digest、thread/turn/item/surface IDs、legacy/mock hit count、开始结束时间和最终 verdict。 + +证据中不得包含 token、cookie、用户输入的敏感字段、绝对凭证路径或完整环境变量。 + +## 最低验证命令 + +按改动范围执行,最终 release gate 至少包括: + +```bash +npm run test:contracts +npm run test:rust:related -- +npm run smoke:agent-runtime-current-fixture +npm run verify:gui-smoke +npm run governance:legacy-report +npm run verify:local +npm run verify:app-version +``` + +Plugin v2 的单一 current Gate B 命令为: + +```bash +npm run smoke:plugin-v2-current-electron-fixture +``` + +该命令稳定前不得删除旧 fixture 的最后覆盖;稳定后应删除旧 plugin runtime/UI worker smoke,而不是永久并存。 + +## 失败分类 + +| 分类 | 判定 | 处理 | +| --------------------------- | ------------------------------------- | ------------------------------------------ | +| Product failure | 用户流程、状态、文案或恢复不符合合同 | 修复后重跑对应场景和相邻场景 | +| Bridge failure | preload/IPC/App Server 不通或走旁路 | Gate B 失败,禁止用 Gate A 替代 | +| Runtime failure | MCP/Skill/Hook 未进入 current item 链 | 修复 owner,不回落 plugin worker | +| Projection failure | runtime 成功但 GUI 状态错误 | 修复 read model/gateway/projection | +| Test infrastructure failure | fixture、端口、构建或环境故障 | 单独标记,不能计为产品通过 | +| External auth failure | 第三方服务不可用或账号缺失 | 保留本地链路证据并标记阻塞,不伪造授权成功 | + +## 验收报告模板 + +```text +主目标: +当前阶段: +平台与应用版本: +场景:GB-xx +结果:PASS / FAIL / BLOCKED +真实链路:Electron -> preload/IPC -> App Server -> RuntimeCore -> read model -> GUI +身份:plugin / thread / turn / item / surface +legacy command hits:0 +mock fallback hits:0 +证据路径: +失败原因与修复: +未验证项: +current / deprecated / dead 变化: +完成度:xx% +``` + +## Release 阻塞条件 + +出现任一项即阻塞发布: + +- P0 Gate B 场景失败或仅有 mock/Gate A 证据。 +- Plugin identity 在 composer、runtime、read model 或 surface 之间断链。 +- 任意 production path 命中旧 plugin worker、旧 manifest 或 renderer registry fallback。 +- 安装/update 可能留下半安装态或覆盖上一可用版本。 +- 卸载会误删共享凭证、用户文件或不可解释残留。 +- macOS/Windows 任一支持平台没有安装到卸载的真实证据。 +- 五语言缺失或窄窗口存在遮挡、变形和不可操作控件。 +- 架构图确认、执行计划或 evidence summary 未落在仓库内。 diff --git a/internal/roadmap/plugin/v2/08-legacy-synthesis.md b/internal/roadmap/plugin/v2/08-legacy-synthesis.md new file mode 100644 index 000000000..fcf9a38e5 --- /dev/null +++ b/internal/roadmap/plugin/v2/08-legacy-synthesis.md @@ -0,0 +1,188 @@ +# 旧 Plugin 文档提炼矩阵 + +状态:`historical-audit-complete` + +更新时间:2026-08-04 + +## 目的 + +旧 Plugin 文档包含已经验证过的产品直觉、交互方案和问题记录,也包含与 Codex/current 主链冲突的历史实现假设。本文件把两者拆开,避免“保留文档”被误解为“保留旧事实源”。 + +原则只有一条:历史文档可以被阅读和提炼,v2 合同才可以驱动实现。 + +## 结论总览 + +| 旧内容 | 处理 | v2 落点 | +| -------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| Plugin 是分发、授权和能力组织的根对象 | 吸收 | [01-product-contract.md](./01-product-contract.md) | +| Claw 是对话、运行、审批和事实链主工作区 | 吸收 | [01-product-contract.md](./01-product-contract.md)、[03-architecture-and-command-contracts.md](./03-architecture-and-command-contracts.md) | +| Right Surface 是唯一物理右栏 | 吸收并收紧 | [04-app-center-and-claw-surfaces.md](./04-app-center-and-claw-surfaces.md) | +| 显式 `@plugin` 激活 | 吸收并改用稳定 plugin identity | [01-product-contract.md](./01-product-contract.md)、[04-app-center-and-claw-surfaces.md](./04-app-center-and-claw-surfaces.md) | +| 历史恢复 selected object、tabs、产物和插件上下文 | 吸收并改为 thread/read model projection | [03-architecture-and-command-contracts.md](./03-architecture-and-command-contracts.md)、[07-verification-contract.md](./07-verification-contract.md) | +| 安装、启用、授权、运行权限分离 | 吸收 | [01-product-contract.md](./01-product-contract.md)、[02-package-marketplace-installation.md](./02-package-marketplace-installation.md) | +| WebContentsView、Host policy、surface action 回流 | 吸收 | [03-architecture-and-command-contracts.md](./03-architecture-and-command-contracts.md)、Browser 路线图 | +| 插件自带 runtime/workbench、任意 renderer contract | 淘汰 | 不进入 v2;统一到 Skills/MCP/Hooks/Right Surface | +| LimeCore marketplace 作为 Lime consumer runtime 的强依赖 | 拆分 | consumer 先支持 bundled/repo/personal/configured/remote;发布平台另行治理 | +| 内容工厂、独立应用壳、发布后台同属 Plugin 根主线 | 拆分 | 内容工厂是插件样例,发布平台是独立范围,不阻塞 P0 consumer | +| 旧 `plugin.json`、`lime.plugin.package.v1` | 淘汰 | `.codex-plugin/plugin.json` | +| 旧 worker 和 renderer mock | 淘汰 | RuntimeCore、MCP、Skills 与 current read model | + +## 按文档提炼 + +### `prd.md`:保留产品问题和用户路径 + +保留内容: + +- 安装、授权、启用、显式激活、产物查看、历史恢复和卸载的完整用户路径。 +- 用户需要知道来源、状态、权限、失败原因和恢复动作。 +- 插件中心不能只是卡片列表,Claw 才是调用和继续工作的主场。 +- 复杂结果应进入 Right Surface,不能把用户带到第二个任务系统。 + +修正内容: + +- 旧 PRD 把发布、注册、租户 enablement 和消费 runtime 交织在一起;v2 把它们分成 consumer protocol、local install store 和独立 publishing scope。 +- 旧 PRD 允许 workspace app/runtime 作为插件贡献;v2 只允许 Skills、MCP servers/apps、Hooks、assets 和受管 UI resource。 + +### `architecture.md`:保留边界图,替换事实源 + +保留内容: + +- Host、App Server、Claw、Runtime、Right Surface、artifact/evidence 的分层思路。 +- Surface 不直接调用 provider、filesystem、secret 或旧 desktop facade。 +- action 必须通过统一 intent 回流 runtime,并产生可追踪 evidence。 +- 历史恢复优先恢复对象和 surface 状态,不重跑危险 action。 + +替换内容: + +- 旧图将 LimeCore marketplace 和本地 registry 合并视为主要事实源;v2 改为 App Server plugin domain 统一 projection。 +- 旧图允许插件 workbench shell 成为能力边界;v2 规定 Right Surface 是宿主边界,插件提供 resource/descriptor,不拥有独立业务后端。 + +### `interface-contracts.md`:保留行为合同,废弃类型合同 + +保留内容: + +- activation intent 的来源区分:user、route、restore、runtime。 +- surface action 的确认、权限、错误和 evidence 回流要求。 +- 历史恢复中的 primary/selected object 优先级。 +- 稳定错误必须面向用户可恢复,而不是暴露 raw JSON。 + +废弃内容: + +- 旧 `PluginManifest`、`activeWorkspaceAppId`、`historyRestore` 和 renderer/workbench contribution 类型不能直接变成 v2 schema。 +- v2 只从 Codex manifest、App Server protocol 和现有 Right Surface contract 重新生成类型。 + +### `technical-baseline.md`:保留 Host 安全底线 + +可直接吸收的底线: + +- Electron Host 管理 view、bounds、权限、错误边界和生命周期。 +- 插件不能自行携带第二套 tab/history/permission/runtime 逻辑。 +- ``、BrowserView、raw Tauri command、生产 mock 不作为新主路径。 +- App Center 的安装/升级/卸载动作必须经过 current readiness 和 policy。 + +需要改写的地方: + +- 旧 baseline 把插件独立 UI runtime 当成可选承载;v2 仅保留 MCP/App UI resource 和 Right Surface host。 +- `articleDraft`、`imageGenerationSet` 等业务 artifact 不属于 Plugin 通用 manifest;它们应由对应领域 projection 定义。 + +### `prototype.md` / `prototype.html`:保留信息架构,不复制视觉实现 + +保留内容: + +- App Center 首页、详情、能力列表、安装状态、composer 激活 strip、Right Surface 和历史恢复的布局关系。 +- 详情页需要把 Overview、Skills、能力/权限、来源和状态分层展示。 +- 用户需要在当前 Claw 上下文看到插件已激活,而不是被跳转到独立应用页。 + +不直接复用: + +- 原型中的 `Renderer` tab、独立 workbench shell 和旧命令名称。 +- 旧视觉尺寸、文案、品牌图标;实现必须遵守 Lime 当前主题和五语言合同。 + +### `history-product-workspace.md`:提炼恢复规则 + +v2 采用以下有效规则: + +1. 先恢复 thread/read model,再恢复 plugin identity 和能力快照。 +2. 再恢复 primary/selected object、surface tab 和布局状态。 +3. 恢复失败显示可理解原因,不暴露内部 JSON 或绝对路径。 +4. 不恢复未完成危险 action、不复活过期权限、不自动重跑外部副作用。 +5. 历史恢复不得抢占用户当前正在使用的 thread 或 Right Surface tab。 + +旧的“工作台应用”命名和业务产物类型不作为 v2 通用协议字段。 + +### `user-operations-guide.md`:提炼运营诊断 + +保留内容: + +- 安装、启用、禁用、卸载、授权和 blocked 状态要分别排查。 +- 诊断需要关联 source、version/digest、policy、auth、runtime readiness 和 evidence。 +- 卸载不应误删共享 connector 凭证;历史记录应保持可读。 +- GUI 验收要覆盖安装态刷新、Right Surface 产物和历史入口。 + +替换内容: + +- “客户端上报到 LimeCore 是唯一安装态事实”的旧说法改为 App Server installed projection;外部审计只消费受控摘要。 +- 旧远端运行和发布授权不进入 v2 P0。 + +### `implementation-plan.md`:保留顺序,冻结进度记录 + +可复用顺序: + +```text +contract + -> marketplace/manifest + -> explicit activation + -> Right Surface + -> history restore + -> evidence +``` + +需要舍弃的路线: + +- 先做大量 renderer registry 和 plugin worker,再补 current runtime。 +- 将内容工厂 dogfood、LimeCore 发布、租户灰度和客户端消费混成一个完成度。 +- 用“骨架完成”替代真实 Electron/App Server/RuntimeCore 证据。 + +旧进度日志保留为历史审计,不再更新;新的进度只写入 `internal/exec-plans/` 和 v2 验收证据。 + +### `e2e-evidence.md` 与 JSON:保留证据结构,不能当 Gate B + +可复用内容: + +- 证据摘要、版本、scenario、安装态、激活态、Right Surface 和失败项应有固定字段。 +- 证据需要同时记录客户端状态和服务端审计,避免只截 UI。 +- 未覆盖项必须明确列出,不把 partial evidence 写成通过。 + +不能直接复用的原因: + +- 旧证据围绕发布中心、LimeCore catalog 和 content-factory fixture,不能证明 v2 的真实 Electron/preload/App Server JSON-RPC/RuntimeCore 链。 +- v2 Gate B 以 [07-verification-contract.md](./07-verification-contract.md) 为唯一验收合同,旧证据只能作为历史背景。 + +### `deverlop/*`:独立范围输入 + +发布中心文档仍有价值,尤其是签名、target 预检、审计、灰度、回滚和证据要求。但它们不应继续放大 Plugin consumer runtime 的范围。 + +处理方式: + +- 当前保留原文作为历史范围输入。 +- v2 只引用其安全和发布边界,不依赖其 API、数据库或 LimeCore endpoint。 +- 未来如重新建设发布平台,应创建独立 roadmap/change,并重新确认 owner、协议和权限。 + +## 采纳后的不变量 + +以下不变量同时来自旧文档的有效经验和 v2 的 current 纠偏: + +1. App Center 是发现、详情和生命周期入口,Claw 是调用和继续工作入口。 +2. App Server 是 Plugin state/readiness 的事实源;Renderer 不扫描目录、不合并 registry。 +3. Plugin 不拥有 agent loop、thread store、独立 worker 或第二右栏。 +4. 所有高风险 action 经过统一权限、确认、事件和 evidence。 +5. 历史恢复恢复状态和引用,不重放旧副作用。 +6. 历史文档可以提供证据和上下文,但不覆盖 v2 protocol、current owner 和 Gate B 结果。 + +## 审计完成条件 + +- 根 README 能导航到历史参考和 v2 current。 +- v2 文档能明确指出旧文档的吸收、替换和暂缓内容。 +- 历史文档没有被标记为 current 实现输入。 +- 新实现计划只引用 v2 文档、current owner 和新的 Gate B evidence。 +- 旧文档保持可读,除非发现敏感数据、断链或明确错误需要单独修订。 diff --git a/internal/roadmap/plugin/v2/README.md b/internal/roadmap/plugin/v2/README.md new file mode 100644 index 000000000..d9f6e4b81 --- /dev/null +++ b/internal/roadmap/plugin/v2/README.md @@ -0,0 +1,82 @@ +# Plugin v2 路线图 + +状态:`active / macOS core Gate B passed` + +更新时间:2026-08-05 + +## 主目标 + +Plugin v2 把 Lime 的插件能力收敛为一条与 Codex 对齐、由 App Server 持有事实、同时适配 Lime App Center 与 Claw 右侧工作区的产品链: + +```text +Marketplace / local package + -> App Server plugin domain + -> installed plugin projection + -> Skills / MCP / Hooks activation + -> RuntimeCore turn context + -> App Center / Claw composer / Right Surface +``` + +本轮不是给旧应用中心换皮,也不是继续扩展 `lime.plugin.package.v1`。目标是直接替换旧包标准、旧前端 registry 合并和旧插件 worker UI 协议,建立唯一继续演进的 Plugin owner。 + +## 事实源声明 + +1. 包结构、marketplace、插件 ID、Skills/MCP/Hooks 组成与安装语义对齐 Codex。 +2. Lime 的生产命令链固定为 `Electron Desktop Host -> App Server JSON-RPC -> RuntimeCore -> GUI`。 +3. App Server 是插件目录、安装状态、启用状态、授权状态和运行时装配的唯一事实源。 +4. Renderer 只消费 projection;不得扫描插件目录、解析 manifest、合并多份 registry 或启动第二套业务后端。 +5. Claw 右侧只承载 MCP/App UI、浏览器或声明式结果投影;插件执行仍归 RuntimeCore、MCP 与工具运行时。 + +## 文档导航 + +| 文档 | 职责 | +| -------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| [00-research-findings.md](./00-research-findings.md) | 官方文档、Codex 源码和本机 Desktop 安装包研究结论 | +| [01-product-contract.md](./01-product-contract.md) | 产品对象、用户流程、状态模型与范围边界 | +| [02-package-marketplace-installation.md](./02-package-marketplace-installation.md) | 包结构、manifest、marketplace、安装与存储合同 | +| [03-architecture-and-command-contracts.md](./03-architecture-and-command-contracts.md) | owner、JSON-RPC、运行时装配与事件投影 | +| [04-app-center-and-claw-surfaces.md](./04-app-center-and-claw-surfaces.md) | App Center、详情、composer mention 与 Claw 右侧交互 | +| [05-migration-and-cleanup.md](./05-migration-and-cleanup.md) | current/deprecated/dead 分类、旧实现退役与历史文档治理 | +| [06-implementation-plan.md](./06-implementation-plan.md) | 分阶段写集、退出条件和主链顺序 | +| [07-verification-contract.md](./07-verification-contract.md) | 单元、协议、集成、Gate A/B 与跨平台验收 | +| [08-legacy-synthesis.md](./08-legacy-synthesis.md) | 旧文档价值提炼、冲突决策与历史参考边界 | + +## 非目标 + +- 不复刻 Codex/ChatGPT 的配色、圆角、字体或图标主题。 +- 不把 Plugin、Skill、MCP、Agent App 继续做成相互独立的四套安装系统。 +- 不在 Plugin v2 首阶段建设公共商业化发布平台、结算或审核后台。 +- 不保留旧 manifest 双读双写、旧 worker fallback 或 renderer mock 生产降级。 +- 不让 Electron main process 成为插件业务后端。 + +## 当前阶段与下一刀 + +当前阶段是 `V2-5 Claw 与 Right Surface / V2-7 release gate`。 + +已完成的 current 主链包括:唯一 `.codex-plugin/plugin.json` manifest、App Server typed catalog/install/enable store、Plugin Skill/MCP activation、结构化 `plugin://` mention、MCP tool/elicitation、MCP App Right Surface 与 Renderer reload 恢复。2026-08-05 fresh Gate B 证据位于 `.lime/qc/gui-evidence/plugin-v2-current-electron-fixture/`。 + +下一刀是把 Gate B 从受控 runtime 安装动作推进到真实 App Center 安装点击、Claw `@` picker 点击和卸载后历史可读,补齐 GB-01/07/13;随后补跨进程 cold restore、Windows 安装到卸载证据和剩余 Apps/Hooks/Browser/file surface。旧实现的物理删除仍须先列出精确目标并取得用户确认,旧版高价值文档继续保留为历史资料。 + +当前路线图完成度:`75%`。核心 macOS runtime/Right Surface 闭环已通过,但尚未达到跨平台 release gate。 + +## 完成定义 + +Plugin v2 只有同时满足以下条件才可标记完成: + +- `.codex-plugin/plugin.json` 成为唯一插件 manifest。 +- bundled、repo、personal、configured、workspace/remote marketplace 可由同一 list projection 表达。 +- 安装、启用、授权、可用性和管理员策略是不同字段,不再压成一个布尔值。 +- 插件 Skills、MCP servers/apps 与 Hooks 在新 thread/turn 中可追踪地装配。 +- App Center 和 Claw 使用同一 plugin identity 与 installed projection。 +- MCP/App UI 能进入 Claw Right Surface,且不存在第二套插件业务后端。 +- 旧包标准、旧前端 registry 与旧 plugin worker UI 路径已删除并有回流守卫;旧路线图冻结为历史参考,不再形成并行事实源。 +- macOS 与 Windows 至少各有一条安装、激活、运行和卸载证据。 +- Gate B 证明真实 Electron、preload/IPC、App Server JSON-RPC、runtime/read model 与用户可见状态闭环。 + +## 参考优先级 + +1. `/Users/coso/Documents/dev/rust/codex` +2. OpenAI 官方 Plugins / Skills / MCP 文档 +3. 本机 `/Applications/ChatGPT.app` 的真实安装包结构 +4. Lime current App Server、MCP、Skills、Right Surface owner +5. `internal/roadmap/plugin/` v1 文件仅作为冻结的历史参考,不再是实现依据 diff --git a/lime-rs/Cargo.lock b/lime-rs/Cargo.lock index 281d45e2f..4c49212ca 100644 --- a/lime-rs/Cargo.lock +++ b/lime-rs/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-protocol" -version = "1.120.1" +version = "1.121.0" dependencies = [ "schemars", "serde", @@ -30,7 +30,7 @@ dependencies = [ [[package]] name = "agent-runtime" -version = "1.120.1" +version = "1.121.0" dependencies = [ "agent-protocol", "anyhow", @@ -181,7 +181,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app-server" -version = "1.120.1" +version = "1.121.0" dependencies = [ "agent-protocol", "agent-runtime", @@ -221,6 +221,7 @@ dependencies = [ "runtime-core", "rusqlite", "scopeguard", + "semver", "serde", "serde_json", "serde_yaml", @@ -242,7 +243,7 @@ dependencies = [ [[package]] name = "app-server-client" -version = "1.120.1" +version = "1.121.0" dependencies = [ "app-server-protocol", "app-server-transport", @@ -253,7 +254,7 @@ dependencies = [ [[package]] name = "app-server-daemon" -version = "1.120.1" +version = "1.121.0" dependencies = [ "app-server-protocol", "app-server-transport", @@ -265,7 +266,7 @@ dependencies = [ [[package]] name = "app-server-protocol" -version = "1.120.1" +version = "1.121.0" dependencies = [ "agent-protocol", "schemars", @@ -275,7 +276,7 @@ dependencies = [ [[package]] name = "app-server-test-client" -version = "1.120.1" +version = "1.121.0" dependencies = [ "anyhow", "app-server-client", @@ -286,7 +287,7 @@ dependencies = [ [[package]] name = "app-server-transport" -version = "1.120.1" +version = "1.121.0" dependencies = [ "app-server-protocol", "axum", @@ -1356,7 +1357,7 @@ dependencies = [ [[package]] name = "document-preview" -version = "1.120.1" +version = "1.121.0" dependencies = [ "flate2", "tempfile", @@ -2619,7 +2620,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.120.1" +version = "1.121.0" dependencies = [ "agent-protocol", "agent-runtime", @@ -2652,7 +2653,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.120.1" +version = "1.121.0" dependencies = [ "chrono", "futures", @@ -2669,7 +2670,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.120.1" +version = "1.121.0" dependencies = [ "clap", "lime-core", @@ -2681,7 +2682,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.120.1" +version = "1.121.0" dependencies = [ "async-trait", "lime-core", @@ -2697,7 +2698,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.120.1" +version = "1.121.0" dependencies = [ "agent-protocol", "async-trait", @@ -2753,7 +2754,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.120.1" +version = "1.121.0" dependencies = [ "aes", "async-trait", @@ -2781,7 +2782,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.120.1" +version = "1.121.0" dependencies = [ "chrono", "dashmap", @@ -2803,7 +2804,7 @@ dependencies = [ [[package]] name = "lime-knowledge" -version = "1.120.1" +version = "1.121.0" dependencies = [ "chrono", "hex", @@ -2816,7 +2817,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.120.1" +version = "1.121.0" dependencies = [ "async-trait", "axum", @@ -2842,7 +2843,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.120.1" +version = "1.121.0" dependencies = [ "axum", "base64 0.22.1", @@ -2861,7 +2862,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.120.1" +version = "1.121.0" dependencies = [ "async-trait", "lime-core", @@ -2901,7 +2902,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.120.1" +version = "1.121.0" dependencies = [ "agent-protocol", "async-stream", @@ -2944,7 +2945,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.120.1" +version = "1.121.0" dependencies = [ "axum", "futures", @@ -2959,7 +2960,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.120.1" +version = "1.121.0" dependencies = [ "anyhow", "async-trait", @@ -3002,7 +3003,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.120.1" +version = "1.121.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -3023,7 +3024,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.120.1" +version = "1.121.0" dependencies = [ "axum", "chrono", @@ -3232,7 +3233,7 @@ dependencies = [ [[package]] name = "model-provider" -version = "1.120.1" +version = "1.121.0" dependencies = [ "agent-protocol", "anyhow", @@ -3902,7 +3903,7 @@ checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" [[package]] name = "patch-apply" -version = "1.120.1" +version = "1.121.0" dependencies = [ "tempfile", "thiserror 1.0.69", @@ -4661,7 +4662,7 @@ dependencies = [ [[package]] name = "runtime-core" -version = "1.120.1" +version = "1.121.0" dependencies = [ "agent-protocol", "app-server-protocol", @@ -5409,7 +5410,7 @@ dependencies = [ [[package]] name = "thread-store" -version = "1.120.1" +version = "1.121.0" dependencies = [ "agent-protocol", "chrono", @@ -5687,7 +5688,7 @@ dependencies = [ [[package]] name = "tool-runtime" -version = "1.120.1" +version = "1.121.0" dependencies = [ "agent-protocol", "app-server-protocol", diff --git a/lime-rs/Cargo.toml b/lime-rs/Cargo.toml index ba0ddbd1d..6760f20b8 100644 --- a/lime-rs/Cargo.toml +++ b/lime-rs/Cargo.toml @@ -6,7 +6,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "1.120.1" +version = "1.121.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" diff --git a/lime-rs/crates/agent-protocol/src/thread.rs b/lime-rs/crates/agent-protocol/src/thread.rs index 6f828458a..47887d869 100644 --- a/lime-rs/crates/agent-protocol/src/thread.rs +++ b/lime-rs/crates/agent-protocol/src/thread.rs @@ -359,6 +359,10 @@ pub enum ThreadItemPayload { call_id: String, server_name: String, tool_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + mcp_app_resource_uri: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + plugin_id: Option, #[serde(default)] arguments: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/lime-rs/crates/agent/src/current_provider_turn/tool_executor.rs b/lime-rs/crates/agent/src/current_provider_turn/tool_executor.rs index 73125ed1c..34cea7544 100644 --- a/lime-rs/crates/agent/src/current_provider_turn/tool_executor.rs +++ b/lime-rs/crates/agent/src/current_provider_turn/tool_executor.rs @@ -1019,6 +1019,8 @@ mod tests { server_name: "docs".to_string(), tool_name: "search".to_string(), runtime_tool_name: "mcp__docs__search".to_string(), + mcp_app_resource_uri: None, + plugin_id: None, }; let result = await_mcp_call(&event_sender, "mcp-call-1", &route, call) diff --git a/lime-rs/crates/agent/src/current_provider_turn/tool_lifecycle_emitter.rs b/lime-rs/crates/agent/src/current_provider_turn/tool_lifecycle_emitter.rs index 8beae51dc..66e787416 100644 --- a/lime-rs/crates/agent/src/current_provider_turn/tool_lifecycle_emitter.rs +++ b/lime-rs/crates/agent/src/current_provider_turn/tool_lifecycle_emitter.rs @@ -241,6 +241,8 @@ fn project_mcp_tool_item( call_id, server_name: route.server_name.clone(), tool_name: route.tool_name.clone(), + mcp_app_resource_uri: route.mcp_app_resource_uri.clone(), + plugin_id: route.plugin_id.clone(), arguments, output, }, @@ -277,6 +279,8 @@ mod tests { server_name: "docs".to_string(), tool_name: "search".to_string(), runtime_tool_name: "docs__search".to_string(), + mcp_app_resource_uri: Some("ui://plugin/docs.html".to_string()), + plugin_id: Some("docs-plugin".to_string()), }]); let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); let emitter = CurrentTurnToolLifecycleEmitter::with_tool_routes( @@ -309,8 +313,13 @@ mod tests { ThreadItemPayload::McpToolCall { ref server_name, ref tool_name, + ref mcp_app_resource_uri, + ref plugin_id, .. - } if server_name == "docs" && tool_name == "search" + } if server_name == "docs" + && tool_name == "search" + && mcp_app_resource_uri.as_deref() == Some("ui://plugin/docs.html") + && plugin_id.as_deref() == Some("docs-plugin") )); } diff --git a/lime-rs/crates/agent/src/mcp_bridge.rs b/lime-rs/crates/agent/src/mcp_bridge.rs index e4be649e6..69f4cd9ba 100644 --- a/lime-rs/crates/agent/src/mcp_bridge.rs +++ b/lime-rs/crates/agent/src/mcp_bridge.rs @@ -82,7 +82,9 @@ impl McpBridgeRuntimeRegistry { McpConnectionProvenance::new( snapshot.environment_id.clone(), snapshot.auth_scopes.clone(), - ), + ) + .with_server_name(Some(snapshot.server_name.clone())) + .with_plugin_id(snapshot.plugin_id.clone()), snapshot.supports_parallel_tool_calls, client, ) diff --git a/lime-rs/crates/agent/src/runtime_state.rs b/lime-rs/crates/agent/src/runtime_state.rs index e1639b0d5..ef280b403 100644 --- a/lime-rs/crates/agent/src/runtime_state.rs +++ b/lime-rs/crates/agent/src/runtime_state.rs @@ -504,6 +504,19 @@ impl AgentRuntimeState { runtime.shutdown().await; } + pub async fn read_mcp_resource( + &self, + session_id: &str, + thread_id: &str, + server_name: &str, + uri: &str, + ) -> Result { + self.mcp_runtime(session_id, thread_id) + .await? + .read_resource(server_name, uri) + .await + } + #[cfg(test)] pub(crate) async fn mcp_runtime_count(&self) -> usize { self.mcp_runtimes.read().await.len() diff --git a/lime-rs/crates/agent/src/runtime_state/mcp_runtime.rs b/lime-rs/crates/agent/src/runtime_state/mcp_runtime.rs index b70dec9b1..9ee352bce 100644 --- a/lime-rs/crates/agent/src/runtime_state/mcp_runtime.rs +++ b/lime-rs/crates/agent/src/runtime_state/mcp_runtime.rs @@ -59,6 +59,17 @@ impl McpThreadRuntime { self.server_specs.clone() } + pub(crate) async fn read_resource( + &self, + server_name: &str, + uri: &str, + ) -> Result { + self.manager + .read_resource(server_name, uri) + .await + .map_err(|error| error.to_string()) + } + pub(crate) async fn start(&self) -> Result<(), String> { let mut unavailable_servers = Vec::new(); let mut required_failures = Vec::new(); @@ -95,11 +106,18 @@ impl McpThreadRuntime { required_failures.join("; ") )); } - let snapshots = self + let mut snapshots = self .manager .bridge_snapshots() .await .map_err(|error| error.to_string())?; + for snapshot in &mut snapshots { + snapshot.plugin_id = self + .server_specs + .iter() + .find(|spec| spec.name == snapshot.server_name) + .and_then(|spec| spec.plugin_id.clone()); + } let bridge_count = self .bridge_registry .sync(&self.connections, snapshots) diff --git a/lime-rs/crates/agent/src/runtime_state/mcp_runtime_tests.rs b/lime-rs/crates/agent/src/runtime_state/mcp_runtime_tests.rs index 71f0cad41..2a31a3604 100644 --- a/lime-rs/crates/agent/src/runtime_state/mcp_runtime_tests.rs +++ b/lime-rs/crates/agent/src/runtime_state/mcp_runtime_tests.rs @@ -17,6 +17,7 @@ fn stdio_server_spec( ) -> McpRuntimeServerSpec { McpRuntimeServerSpec { name: name.to_string(), + plugin_id: None, config: McpServerConfig { transport: McpServerTransport::Stdio { command, @@ -232,6 +233,111 @@ lines.on("line", (line) => { state.clear_mcp_runtimes().await; } +#[tokio::test] +async fn session_owned_mcp_runtime_reads_app_resource_with_meta() { + let Some(node) = node_binary() else { + return; + }; + let temp_dir = tempfile::tempdir().expect("create runtime MCP app fixture directory"); + let server_path = temp_dir.path().join("mcp-app-resource-server.mjs"); + std::fs::write( + &server_path, + r#" +import readline from "node:readline"; + +const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\n"); + +lines.on("line", (line) => { + if (!line.trim()) return; + const message = JSON.parse(line); + if (message.method === "initialize") { + send({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "2025-03-26", + capabilities: { resources: {}, tools: {} }, + serverInfo: { name: "mcp-app-resource-fixture", version: "1.0.0" }, + }, + }); + return; + } + if (message.method === "notifications/initialized") return; + if (message.method === "tools/list") { + send({ jsonrpc: "2.0", id: message.id, result: { tools: [] } }); + return; + } + if (message.method === "resources/read") { + send({ + jsonrpc: "2.0", + id: message.id, + result: { + contents: [{ + uri: message.params?.uri, + mimeType: "text/html;profile=mcp-app", + text: "
MCP App
", + _meta: { + ui: { csp: { connectDomains: ["https://api.example.com"] } }, + }, + }], + }, + }); + } +}); +"#, + ) + .expect("write runtime MCP app fixture"); + + let state = AgentRuntimeState::new(); + state + .ensure_mcp_runtime_generation( + "session-app".to_string(), + "thread-app".to_string(), + ElicitationRequestRouter::default(), + vec![stdio_server_spec( + "plugin__demo__server", + node, + vec![server_path.to_string_lossy().into_owned()], + true, + )], + ) + .await + .expect("publish MCP App runtime generation"); + + let content = state + .read_mcp_resource( + "session-app", + "thread-app", + "plugin__demo__server", + "ui://demo/report.html", + ) + .await + .expect("read MCP App resource from session runtime"); + assert_eq!(content.uri, "ui://demo/report.html"); + assert_eq!( + content.mime_type.as_deref(), + Some("text/html;profile=mcp-app") + ); + assert_eq!( + content + .meta + .as_ref() + .and_then(|meta| meta.pointer("/ui/csp/connectDomains/0")), + Some(&serde_json::json!("https://api.example.com")) + ); + assert!(state + .read_mcp_resource( + "session-app", + "thread-other", + "plugin__demo__server", + "ui://demo/report.html", + ) + .await + .is_err()); + state.clear_mcp_runtimes().await; +} + #[tokio::test] async fn required_runtime_server_failure_keeps_the_previous_generation_published() { let state = AgentRuntimeState::new(); diff --git a/lime-rs/crates/app-server-client/src/lib.rs b/lime-rs/crates/app-server-client/src/lib.rs index 6a52bc0cb..a5f42f2a2 100644 --- a/lime-rs/crates/app-server-client/src/lib.rs +++ b/lime-rs/crates/app-server-client/src/lib.rs @@ -3128,6 +3128,8 @@ mod tests { .read_mcp_resource(McpResourceReadParams { server: "filesystem".to_string(), uri: "file:///workspace/README.md".to_string(), + session_id: None, + thread_id: None, }) .expect("resource"); let resource_subscription = client @@ -3256,6 +3258,8 @@ mod tests { let read = client.read_mcp_resource(McpResourceReadParams { server: " ".to_string(), uri: "docs://readme".to_string(), + session_id: None, + thread_id: None, }); let subscribe = client.subscribe_mcp_resource(McpResourceSubscribeParams { server: "docs".to_string(), diff --git a/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json b/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json index 54e4e43c5..1a6e39b2a 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json +++ b/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json @@ -11544,6 +11544,99 @@ } ] }, + "PluginCatalogEnabledSetParams": { + "properties": { + "enabled": { + "type": "boolean" + }, + "pluginId": { + "type": "string" + } + }, + "required": [ + "enabled", + "pluginId" + ], + "type": "object" + }, + "PluginCatalogInstallParams": { + "properties": { + "expectedDigest": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": [ + "null", + "string" + ] + }, + "source": { + "type": [ + "null", + "string" + ] + }, + "sourcePath": { + "type": "string" + } + }, + "required": [ + "sourcePath" + ], + "type": "object" + }, + "PluginCatalogInstalledParams": { + "type": "object" + }, + "PluginCatalogListParams": { + "properties": { + "marketplacePaths": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "query": { + "type": [ + "null", + "string" + ] + }, + "source": { + "type": [ + "null", + "string" + ] + } + }, + "type": "object" + }, + "PluginCatalogReadParams": { + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "type": "object" + }, + "PluginCatalogUninstallParams": { + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "type": "object" + }, "RequestId": { "anyOf": [ { @@ -13712,6 +13805,126 @@ ], "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "const": "plugin/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PluginCatalogListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "const": "plugin/read", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PluginCatalogReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "const": "plugin/install", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PluginCatalogInstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "const": "plugin/uninstall", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PluginCatalogUninstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "const": "plugin/installed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PluginCatalogInstalledParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "const": "plugin/enabled/set", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PluginCatalogEnabledSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "type": "object" + }, { "properties": { "id": { @@ -29308,6 +29521,20 @@ "minLength": 1, "type": "string" }, + "sessionId": { + "minLength": 1, + "type": [ + "null", + "string" + ] + }, + "threadId": { + "minLength": 1, + "type": [ + "null", + "string" + ] + }, "uri": { "minLength": 1, "type": "string" @@ -29329,6 +29556,7 @@ "string" ] }, + "meta": true, "mime_type": { "type": [ "null", @@ -32658,6 +32886,12 @@ "artifact/write", "media/read", "model/list", + "plugin/enabled/set", + "plugin/install", + "plugin/installed", + "plugin/list", + "plugin/read", + "plugin/uninstall", "thread/approveGuardianDeniedAction", "thread/archive", "thread/backgroundTerminals/clean", @@ -38139,6 +38373,1040 @@ "title": "PluginArticleWorkspaceObject", "type": "object" }, + "PluginCatalogCapability": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "requiresAuth": { + "type": "boolean" + } + }, + "required": [ + "description", + "id", + "name", + "requiresAuth" + ], + "title": "PluginCatalogCapability", + "type": "object" + }, + "PluginCatalogDetail": { + "$defs": { + "PluginCatalogCapability": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "requiresAuth": { + "type": "boolean" + } + }, + "required": [ + "description", + "id", + "name", + "requiresAuth" + ], + "type": "object" + }, + "PluginCatalogHook": { + "properties": { + "event": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "event", + "id" + ], + "type": "object" + }, + "PluginCatalogSummary": { + "properties": { + "appsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "authPolicy": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "contentDigest": { + "type": "string" + }, + "description": { + "type": "string" + }, + "disabledReason": { + "type": [ + "null", + "string" + ] + }, + "enabled": { + "type": "boolean" + }, + "hooksCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "localVersion": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": "string" + }, + "mcpServersCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "name": { + "type": "string" + }, + "skillsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "sourceUri": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "appsCount", + "authPolicy", + "availability", + "contentDigest", + "description", + "enabled", + "hooksCount", + "id", + "installPolicy", + "installed", + "marketplaceId", + "mcpServersCount", + "name", + "skillsCount", + "source", + "sourceUri", + "version" + ], + "type": "object" + }, + "PluginCatalogUiResource": { + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "resourceUri": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "resourceUri" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "apps": { + "items": { + "$ref": "#/$defs/PluginCatalogCapability" + }, + "type": "array" + }, + "hooks": { + "items": { + "$ref": "#/$defs/PluginCatalogHook" + }, + "type": "array" + }, + "mcpServers": { + "items": { + "$ref": "#/$defs/PluginCatalogCapability" + }, + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/$defs/PluginCatalogCapability" + }, + "type": "array" + }, + "summary": { + "$ref": "#/$defs/PluginCatalogSummary" + }, + "uiResources": { + "items": { + "$ref": "#/$defs/PluginCatalogUiResource" + }, + "type": "array" + } + }, + "required": [ + "apps", + "hooks", + "mcpServers", + "skills", + "summary", + "uiResources" + ], + "title": "PluginCatalogDetail", + "type": "object" + }, + "PluginCatalogEnabledSetParams": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "enabled": { + "type": "boolean" + }, + "pluginId": { + "type": "string" + } + }, + "required": [ + "enabled", + "pluginId" + ], + "title": "PluginCatalogEnabledSetParams", + "type": "object" + }, + "PluginCatalogEnabledSetResponse": { + "$defs": { + "PluginCatalogSummary": { + "properties": { + "appsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "authPolicy": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "contentDigest": { + "type": "string" + }, + "description": { + "type": "string" + }, + "disabledReason": { + "type": [ + "null", + "string" + ] + }, + "enabled": { + "type": "boolean" + }, + "hooksCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "localVersion": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": "string" + }, + "mcpServersCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "name": { + "type": "string" + }, + "skillsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "sourceUri": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "appsCount", + "authPolicy", + "availability", + "contentDigest", + "description", + "enabled", + "hooksCount", + "id", + "installPolicy", + "installed", + "marketplaceId", + "mcpServersCount", + "name", + "skillsCount", + "source", + "sourceUri", + "version" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "plugin": { + "$ref": "#/$defs/PluginCatalogSummary" + } + }, + "required": [ + "plugin" + ], + "title": "PluginCatalogEnabledSetResponse", + "type": "object" + }, + "PluginCatalogHook": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "event": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "event", + "id" + ], + "title": "PluginCatalogHook", + "type": "object" + }, + "PluginCatalogInstallParams": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "expectedDigest": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": [ + "null", + "string" + ] + }, + "source": { + "type": [ + "null", + "string" + ] + }, + "sourcePath": { + "type": "string" + } + }, + "required": [ + "sourcePath" + ], + "title": "PluginCatalogInstallParams", + "type": "object" + }, + "PluginCatalogInstallResponse": { + "$defs": { + "PluginCatalogSummary": { + "properties": { + "appsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "authPolicy": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "contentDigest": { + "type": "string" + }, + "description": { + "type": "string" + }, + "disabledReason": { + "type": [ + "null", + "string" + ] + }, + "enabled": { + "type": "boolean" + }, + "hooksCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "localVersion": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": "string" + }, + "mcpServersCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "name": { + "type": "string" + }, + "skillsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "sourceUri": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "appsCount", + "authPolicy", + "availability", + "contentDigest", + "description", + "enabled", + "hooksCount", + "id", + "installPolicy", + "installed", + "marketplaceId", + "mcpServersCount", + "name", + "skillsCount", + "source", + "sourceUri", + "version" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "plugin": { + "$ref": "#/$defs/PluginCatalogSummary" + } + }, + "required": [ + "plugin" + ], + "title": "PluginCatalogInstallResponse", + "type": "object" + }, + "PluginCatalogInstalledParams": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PluginCatalogInstalledParams", + "type": "object" + }, + "PluginCatalogListParams": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "marketplacePaths": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "query": { + "type": [ + "null", + "string" + ] + }, + "source": { + "type": [ + "null", + "string" + ] + } + }, + "title": "PluginCatalogListParams", + "type": "object" + }, + "PluginCatalogListResponse": { + "$defs": { + "PluginCatalogSummary": { + "properties": { + "appsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "authPolicy": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "contentDigest": { + "type": "string" + }, + "description": { + "type": "string" + }, + "disabledReason": { + "type": [ + "null", + "string" + ] + }, + "enabled": { + "type": "boolean" + }, + "hooksCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "localVersion": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": "string" + }, + "mcpServersCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "name": { + "type": "string" + }, + "skillsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "sourceUri": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "appsCount", + "authPolicy", + "availability", + "contentDigest", + "description", + "enabled", + "hooksCount", + "id", + "installPolicy", + "installed", + "marketplaceId", + "mcpServersCount", + "name", + "skillsCount", + "source", + "sourceUri", + "version" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "generatedAt": { + "type": "string" + }, + "plugins": { + "items": { + "$ref": "#/$defs/PluginCatalogSummary" + }, + "type": "array" + } + }, + "required": [ + "generatedAt", + "plugins" + ], + "title": "PluginCatalogListResponse", + "type": "object" + }, + "PluginCatalogReadParams": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "title": "PluginCatalogReadParams", + "type": "object" + }, + "PluginCatalogReadResponse": { + "$defs": { + "PluginCatalogCapability": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "requiresAuth": { + "type": "boolean" + } + }, + "required": [ + "description", + "id", + "name", + "requiresAuth" + ], + "type": "object" + }, + "PluginCatalogDetail": { + "properties": { + "apps": { + "items": { + "$ref": "#/$defs/PluginCatalogCapability" + }, + "type": "array" + }, + "hooks": { + "items": { + "$ref": "#/$defs/PluginCatalogHook" + }, + "type": "array" + }, + "mcpServers": { + "items": { + "$ref": "#/$defs/PluginCatalogCapability" + }, + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/$defs/PluginCatalogCapability" + }, + "type": "array" + }, + "summary": { + "$ref": "#/$defs/PluginCatalogSummary" + }, + "uiResources": { + "items": { + "$ref": "#/$defs/PluginCatalogUiResource" + }, + "type": "array" + } + }, + "required": [ + "apps", + "hooks", + "mcpServers", + "skills", + "summary", + "uiResources" + ], + "type": "object" + }, + "PluginCatalogHook": { + "properties": { + "event": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "event", + "id" + ], + "type": "object" + }, + "PluginCatalogSummary": { + "properties": { + "appsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "authPolicy": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "contentDigest": { + "type": "string" + }, + "description": { + "type": "string" + }, + "disabledReason": { + "type": [ + "null", + "string" + ] + }, + "enabled": { + "type": "boolean" + }, + "hooksCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "localVersion": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": "string" + }, + "mcpServersCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "name": { + "type": "string" + }, + "skillsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "sourceUri": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "appsCount", + "authPolicy", + "availability", + "contentDigest", + "description", + "enabled", + "hooksCount", + "id", + "installPolicy", + "installed", + "marketplaceId", + "mcpServersCount", + "name", + "skillsCount", + "source", + "sourceUri", + "version" + ], + "type": "object" + }, + "PluginCatalogUiResource": { + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "resourceUri": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "resourceUri" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "plugin": { + "$ref": "#/$defs/PluginCatalogDetail" + } + }, + "required": [ + "plugin" + ], + "title": "PluginCatalogReadResponse", + "type": "object" + }, + "PluginCatalogSummary": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "appsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "authPolicy": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "contentDigest": { + "type": "string" + }, + "description": { + "type": "string" + }, + "disabledReason": { + "type": [ + "null", + "string" + ] + }, + "enabled": { + "type": "boolean" + }, + "hooksCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "localVersion": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": "string" + }, + "mcpServersCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "name": { + "type": "string" + }, + "skillsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "sourceUri": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "appsCount", + "authPolicy", + "availability", + "contentDigest", + "description", + "enabled", + "hooksCount", + "id", + "installPolicy", + "installed", + "marketplaceId", + "mcpServersCount", + "name", + "skillsCount", + "source", + "sourceUri", + "version" + ], + "title": "PluginCatalogSummary", + "type": "object" + }, + "PluginCatalogUiResource": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "resourceUri": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "resourceUri" + ], + "title": "PluginCatalogUiResource", + "type": "object" + }, + "PluginCatalogUninstallParams": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "title": "PluginCatalogUninstallParams", + "type": "object" + }, + "PluginCatalogUninstallResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "pluginId": { + "type": "string" + }, + "uninstalled": { + "type": "boolean" + } + }, + "required": [ + "pluginId", + "uninstalled" + ], + "title": "PluginCatalogUninstallResponse", + "type": "object" + }, "PluginCloudReleaseDescriptor": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -100464,6 +101732,12 @@ "modelProviderUiState/read", "modelProviderUiState/write", "modelSyncState/read", + "plugin/enabled/set", + "plugin/install", + "plugin/installed", + "plugin/list", + "plugin/read", + "plugin/uninstall", "pluginHostLifecycle/list", "pluginInstalled/disabled/set", "pluginInstalled/list", diff --git a/lime-rs/crates/app-server-protocol/schema/json/manifest.json b/lime-rs/crates/app-server-protocol/schema/json/manifest.json index 800e796cf..0ced86eb6 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/manifest.json +++ b/lime-rs/crates/app-server-protocol/schema/json/manifest.json @@ -750,6 +750,30 @@ "kind": "request", "method": "modelSyncState/read" }, + { + "kind": "request", + "method": "plugin/enabled/set" + }, + { + "kind": "request", + "method": "plugin/install" + }, + { + "kind": "request", + "method": "plugin/installed" + }, + { + "kind": "request", + "method": "plugin/list" + }, + { + "kind": "request", + "method": "plugin/read" + }, + { + "kind": "request", + "method": "plugin/uninstall" + }, { "kind": "request", "method": "pluginHostLifecycle/list" @@ -2285,6 +2309,22 @@ "PermissionsRequestApprovalParams", "PermissionsRequestApprovalResponse", "PlanDeltaNotification", + "PluginCatalogCapability", + "PluginCatalogDetail", + "PluginCatalogEnabledSetParams", + "PluginCatalogEnabledSetResponse", + "PluginCatalogHook", + "PluginCatalogInstallParams", + "PluginCatalogInstallResponse", + "PluginCatalogInstalledParams", + "PluginCatalogListParams", + "PluginCatalogListResponse", + "PluginCatalogReadParams", + "PluginCatalogReadResponse", + "PluginCatalogSummary", + "PluginCatalogUiResource", + "PluginCatalogUninstallParams", + "PluginCatalogUninstallResponse", "ReasoningEffortOption", "ReasoningSummaryPartAddedNotification", "ReasoningSummaryTextDeltaNotification", diff --git a/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadParams.json b/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadParams.json index 6bcb90b59..d100efa1c 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadParams.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadParams.json @@ -5,6 +5,20 @@ "minLength": 1, "type": "string" }, + "sessionId": { + "minLength": 1, + "type": [ + "null", + "string" + ] + }, + "threadId": { + "minLength": 1, + "type": [ + "null", + "string" + ] + }, "uri": { "minLength": 1, "type": "string" diff --git a/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadResponse.json index 78e43a5e1..1b30a0a44 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadResponse.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadResponse.json @@ -7,6 +7,7 @@ "string" ] }, + "meta": true, "mime_type": { "type": [ "null", diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ClientRequest.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ClientRequest.json index 7cddb6e5f..6409f7574 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ClientRequest.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ClientRequest.json @@ -351,6 +351,99 @@ } ] }, + "PluginCatalogEnabledSetParams": { + "properties": { + "enabled": { + "type": "boolean" + }, + "pluginId": { + "type": "string" + } + }, + "required": [ + "enabled", + "pluginId" + ], + "type": "object" + }, + "PluginCatalogInstallParams": { + "properties": { + "expectedDigest": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": [ + "null", + "string" + ] + }, + "source": { + "type": [ + "null", + "string" + ] + }, + "sourcePath": { + "type": "string" + } + }, + "required": [ + "sourcePath" + ], + "type": "object" + }, + "PluginCatalogInstalledParams": { + "type": "object" + }, + "PluginCatalogListParams": { + "properties": { + "marketplacePaths": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "query": { + "type": [ + "null", + "string" + ] + }, + "source": { + "type": [ + "null", + "string" + ] + } + }, + "type": "object" + }, + "PluginCatalogReadParams": { + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "type": "object" + }, + "PluginCatalogUninstallParams": { + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "type": "object" + }, "RequestId": { "anyOf": [ { @@ -2519,6 +2612,126 @@ ], "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "const": "plugin/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PluginCatalogListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "const": "plugin/read", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PluginCatalogReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "const": "plugin/install", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PluginCatalogInstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "const": "plugin/uninstall", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PluginCatalogUninstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "const": "plugin/installed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PluginCatalogInstalledParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "method": { + "const": "plugin/enabled/set", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PluginCatalogEnabledSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "type": "object" + }, { "properties": { "id": { diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/Method.json b/lime-rs/crates/app-server-protocol/schema/json/v2/Method.json index 03bd74d49..1c0f6c99d 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/Method.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/Method.json @@ -4,6 +4,12 @@ "artifact/write", "media/read", "model/list", + "plugin/enabled/set", + "plugin/install", + "plugin/installed", + "plugin/list", + "plugin/read", + "plugin/uninstall", "thread/approveGuardianDeniedAction", "thread/archive", "thread/backgroundTerminals/clean", diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogCapability.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogCapability.json new file mode 100644 index 000000000..0f7d328d3 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogCapability.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "requiresAuth": { + "type": "boolean" + } + }, + "required": [ + "description", + "id", + "name", + "requiresAuth" + ], + "title": "PluginCatalogCapability", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogDetail.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogDetail.json new file mode 100644 index 000000000..e93747712 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogDetail.json @@ -0,0 +1,202 @@ +{ + "$defs": { + "PluginCatalogCapability": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "requiresAuth": { + "type": "boolean" + } + }, + "required": [ + "description", + "id", + "name", + "requiresAuth" + ], + "type": "object" + }, + "PluginCatalogHook": { + "properties": { + "event": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "event", + "id" + ], + "type": "object" + }, + "PluginCatalogSummary": { + "properties": { + "appsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "authPolicy": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "contentDigest": { + "type": "string" + }, + "description": { + "type": "string" + }, + "disabledReason": { + "type": [ + "null", + "string" + ] + }, + "enabled": { + "type": "boolean" + }, + "hooksCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "localVersion": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": "string" + }, + "mcpServersCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "name": { + "type": "string" + }, + "skillsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "sourceUri": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "appsCount", + "authPolicy", + "availability", + "contentDigest", + "description", + "enabled", + "hooksCount", + "id", + "installPolicy", + "installed", + "marketplaceId", + "mcpServersCount", + "name", + "skillsCount", + "source", + "sourceUri", + "version" + ], + "type": "object" + }, + "PluginCatalogUiResource": { + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "resourceUri": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "resourceUri" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "apps": { + "items": { + "$ref": "#/$defs/PluginCatalogCapability" + }, + "type": "array" + }, + "hooks": { + "items": { + "$ref": "#/$defs/PluginCatalogHook" + }, + "type": "array" + }, + "mcpServers": { + "items": { + "$ref": "#/$defs/PluginCatalogCapability" + }, + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/$defs/PluginCatalogCapability" + }, + "type": "array" + }, + "summary": { + "$ref": "#/$defs/PluginCatalogSummary" + }, + "uiResources": { + "items": { + "$ref": "#/$defs/PluginCatalogUiResource" + }, + "type": "array" + } + }, + "required": [ + "apps", + "hooks", + "mcpServers", + "skills", + "summary", + "uiResources" + ], + "title": "PluginCatalogDetail", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogEnabledSetParams.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogEnabledSetParams.json new file mode 100644 index 000000000..029543b07 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogEnabledSetParams.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "enabled": { + "type": "boolean" + }, + "pluginId": { + "type": "string" + } + }, + "required": [ + "enabled", + "pluginId" + ], + "title": "PluginCatalogEnabledSetParams", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogEnabledSetResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogEnabledSetResponse.json new file mode 100644 index 000000000..04a14a880 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogEnabledSetResponse.json @@ -0,0 +1,110 @@ +{ + "$defs": { + "PluginCatalogSummary": { + "properties": { + "appsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "authPolicy": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "contentDigest": { + "type": "string" + }, + "description": { + "type": "string" + }, + "disabledReason": { + "type": [ + "null", + "string" + ] + }, + "enabled": { + "type": "boolean" + }, + "hooksCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "localVersion": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": "string" + }, + "mcpServersCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "name": { + "type": "string" + }, + "skillsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "sourceUri": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "appsCount", + "authPolicy", + "availability", + "contentDigest", + "description", + "enabled", + "hooksCount", + "id", + "installPolicy", + "installed", + "marketplaceId", + "mcpServersCount", + "name", + "skillsCount", + "source", + "sourceUri", + "version" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "plugin": { + "$ref": "#/$defs/PluginCatalogSummary" + } + }, + "required": [ + "plugin" + ], + "title": "PluginCatalogEnabledSetResponse", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogHook.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogHook.json new file mode 100644 index 000000000..91498623f --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogHook.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "event": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "event", + "id" + ], + "title": "PluginCatalogHook", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstallParams.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstallParams.json new file mode 100644 index 000000000..1c66bf84a --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstallParams.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "expectedDigest": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": [ + "null", + "string" + ] + }, + "source": { + "type": [ + "null", + "string" + ] + }, + "sourcePath": { + "type": "string" + } + }, + "required": [ + "sourcePath" + ], + "title": "PluginCatalogInstallParams", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstallResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstallResponse.json new file mode 100644 index 000000000..8fadc688c --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstallResponse.json @@ -0,0 +1,110 @@ +{ + "$defs": { + "PluginCatalogSummary": { + "properties": { + "appsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "authPolicy": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "contentDigest": { + "type": "string" + }, + "description": { + "type": "string" + }, + "disabledReason": { + "type": [ + "null", + "string" + ] + }, + "enabled": { + "type": "boolean" + }, + "hooksCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "localVersion": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": "string" + }, + "mcpServersCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "name": { + "type": "string" + }, + "skillsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "sourceUri": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "appsCount", + "authPolicy", + "availability", + "contentDigest", + "description", + "enabled", + "hooksCount", + "id", + "installPolicy", + "installed", + "marketplaceId", + "mcpServersCount", + "name", + "skillsCount", + "source", + "sourceUri", + "version" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "plugin": { + "$ref": "#/$defs/PluginCatalogSummary" + } + }, + "required": [ + "plugin" + ], + "title": "PluginCatalogInstallResponse", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstalledParams.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstalledParams.json new file mode 100644 index 000000000..cb4bbc97e --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstalledParams.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PluginCatalogInstalledParams", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogListParams.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogListParams.json new file mode 100644 index 000000000..a0a4aba23 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogListParams.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "marketplacePaths": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "query": { + "type": [ + "null", + "string" + ] + }, + "source": { + "type": [ + "null", + "string" + ] + } + }, + "title": "PluginCatalogListParams", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogListResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogListResponse.json new file mode 100644 index 000000000..f5f051f88 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogListResponse.json @@ -0,0 +1,117 @@ +{ + "$defs": { + "PluginCatalogSummary": { + "properties": { + "appsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "authPolicy": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "contentDigest": { + "type": "string" + }, + "description": { + "type": "string" + }, + "disabledReason": { + "type": [ + "null", + "string" + ] + }, + "enabled": { + "type": "boolean" + }, + "hooksCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "localVersion": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": "string" + }, + "mcpServersCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "name": { + "type": "string" + }, + "skillsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "sourceUri": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "appsCount", + "authPolicy", + "availability", + "contentDigest", + "description", + "enabled", + "hooksCount", + "id", + "installPolicy", + "installed", + "marketplaceId", + "mcpServersCount", + "name", + "skillsCount", + "source", + "sourceUri", + "version" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "generatedAt": { + "type": "string" + }, + "plugins": { + "items": { + "$ref": "#/$defs/PluginCatalogSummary" + }, + "type": "array" + } + }, + "required": [ + "generatedAt", + "plugins" + ], + "title": "PluginCatalogListResponse", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogReadParams.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogReadParams.json new file mode 100644 index 000000000..c2b1628f7 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogReadParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "title": "PluginCatalogReadParams", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogReadResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogReadResponse.json new file mode 100644 index 000000000..f93f7e647 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogReadResponse.json @@ -0,0 +1,213 @@ +{ + "$defs": { + "PluginCatalogCapability": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "requiresAuth": { + "type": "boolean" + } + }, + "required": [ + "description", + "id", + "name", + "requiresAuth" + ], + "type": "object" + }, + "PluginCatalogDetail": { + "properties": { + "apps": { + "items": { + "$ref": "#/$defs/PluginCatalogCapability" + }, + "type": "array" + }, + "hooks": { + "items": { + "$ref": "#/$defs/PluginCatalogHook" + }, + "type": "array" + }, + "mcpServers": { + "items": { + "$ref": "#/$defs/PluginCatalogCapability" + }, + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/$defs/PluginCatalogCapability" + }, + "type": "array" + }, + "summary": { + "$ref": "#/$defs/PluginCatalogSummary" + }, + "uiResources": { + "items": { + "$ref": "#/$defs/PluginCatalogUiResource" + }, + "type": "array" + } + }, + "required": [ + "apps", + "hooks", + "mcpServers", + "skills", + "summary", + "uiResources" + ], + "type": "object" + }, + "PluginCatalogHook": { + "properties": { + "event": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "event", + "id" + ], + "type": "object" + }, + "PluginCatalogSummary": { + "properties": { + "appsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "authPolicy": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "contentDigest": { + "type": "string" + }, + "description": { + "type": "string" + }, + "disabledReason": { + "type": [ + "null", + "string" + ] + }, + "enabled": { + "type": "boolean" + }, + "hooksCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "localVersion": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": "string" + }, + "mcpServersCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "name": { + "type": "string" + }, + "skillsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "sourceUri": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "appsCount", + "authPolicy", + "availability", + "contentDigest", + "description", + "enabled", + "hooksCount", + "id", + "installPolicy", + "installed", + "marketplaceId", + "mcpServersCount", + "name", + "skillsCount", + "source", + "sourceUri", + "version" + ], + "type": "object" + }, + "PluginCatalogUiResource": { + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "resourceUri": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "resourceUri" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "plugin": { + "$ref": "#/$defs/PluginCatalogDetail" + } + }, + "required": [ + "plugin" + ], + "title": "PluginCatalogReadResponse", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogSummary.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogSummary.json new file mode 100644 index 000000000..abcbabc90 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogSummary.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "appsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "authPolicy": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "contentDigest": { + "type": "string" + }, + "description": { + "type": "string" + }, + "disabledReason": { + "type": [ + "null", + "string" + ] + }, + "enabled": { + "type": "boolean" + }, + "hooksCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "localVersion": { + "type": [ + "null", + "string" + ] + }, + "marketplaceId": { + "type": "string" + }, + "mcpServersCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "name": { + "type": "string" + }, + "skillsCount": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "sourceUri": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "appsCount", + "authPolicy", + "availability", + "contentDigest", + "description", + "enabled", + "hooksCount", + "id", + "installPolicy", + "installed", + "marketplaceId", + "mcpServersCount", + "name", + "skillsCount", + "source", + "sourceUri", + "version" + ], + "title": "PluginCatalogSummary", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUiResource.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUiResource.json new file mode 100644 index 000000000..6c35c14a7 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUiResource.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "resourceUri": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "resourceUri" + ], + "title": "PluginCatalogUiResource", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUninstallParams.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUninstallParams.json new file mode 100644 index 000000000..56233f133 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUninstallParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "title": "PluginCatalogUninstallParams", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUninstallResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUninstallResponse.json new file mode 100644 index 000000000..986062136 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUninstallResponse.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "pluginId": { + "type": "string" + }, + "uninstalled": { + "type": "boolean" + } + }, + "required": [ + "pluginId", + "uninstalled" + ], + "title": "PluginCatalogUninstallResponse", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v0/mcp.rs b/lime-rs/crates/app-server-protocol/src/protocol/v0/mcp.rs index 5a2879d25..50f4bbb4f 100644 --- a/lime-rs/crates/app-server-protocol/src/protocol/v0/mcp.rs +++ b/lime-rs/crates/app-server-protocol/src/protocol/v0/mcp.rs @@ -190,6 +190,12 @@ pub struct McpResourceReadParams { pub server: String, #[schemars(length(min = 1))] pub uri: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] + pub thread_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -223,6 +229,8 @@ pub struct McpResourceReadResponse { pub text: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub blob: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub meta: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/envelopes.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/envelopes.rs index afd90b277..7412ec849 100644 --- a/lime-rs/crates/app-server-protocol/src/protocol/v2/envelopes.rs +++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/envelopes.rs @@ -9,7 +9,9 @@ use super::{ McpToolCallProgressNotification, MediaReadParams, MediaReadResponse, Method, ModelListParams, ModelListUpdatedNotification, ModelReroutedNotification, ModelSafetyBufferingUpdatedNotification, ModelVerificationNotification, - PermissionsRequestApprovalParams, PlanDeltaNotification, ReasoningSummaryPartAddedNotification, + PermissionsRequestApprovalParams, PlanDeltaNotification, PluginCatalogEnabledSetParams, + PluginCatalogInstallParams, PluginCatalogInstalledParams, PluginCatalogListParams, + PluginCatalogReadParams, PluginCatalogUninstallParams, ReasoningSummaryPartAddedNotification, ReasoningSummaryTextDeltaNotification, ReasoningTextDeltaNotification, ServerRequestResolvedNotification, SkillsChangedNotification, ThreadApproveGuardianDeniedActionParams, ThreadApproveGuardianDeniedActionResponse, @@ -236,6 +238,36 @@ pub enum ClientRequest { id: RequestId, params: ModelListParams, }, + #[serde(rename = "plugin/list")] + PluginList { + id: RequestId, + params: PluginCatalogListParams, + }, + #[serde(rename = "plugin/read")] + PluginRead { + id: RequestId, + params: PluginCatalogReadParams, + }, + #[serde(rename = "plugin/install")] + PluginInstall { + id: RequestId, + params: PluginCatalogInstallParams, + }, + #[serde(rename = "plugin/uninstall")] + PluginUninstall { + id: RequestId, + params: PluginCatalogUninstallParams, + }, + #[serde(rename = "plugin/installed")] + PluginInstalled { + id: RequestId, + params: PluginCatalogInstalledParams, + }, + #[serde(rename = "plugin/enabled/set")] + PluginEnabledSet { + id: RequestId, + params: PluginCatalogEnabledSetParams, + }, #[serde(rename = "turn/start")] TurnStart { id: RequestId, @@ -289,6 +321,12 @@ impl ClientRequest { | Self::ArtifactWrite { id, .. } | Self::MediaRead { id, .. } | Self::ModelList { id, .. } + | Self::PluginList { id, .. } + | Self::PluginRead { id, .. } + | Self::PluginInstall { id, .. } + | Self::PluginUninstall { id, .. } + | Self::PluginInstalled { id, .. } + | Self::PluginEnabledSet { id, .. } | Self::TurnStart { id, .. } | Self::TurnSteer { id, .. } | Self::TurnInterrupt { id, .. } => id, @@ -334,6 +372,12 @@ impl ClientRequest { Self::ArtifactWrite { .. } => Method::ArtifactWrite, Self::MediaRead { .. } => Method::MediaRead, Self::ModelList { .. } => Method::ModelList, + Self::PluginList { .. } => Method::PluginList, + Self::PluginRead { .. } => Method::PluginRead, + Self::PluginInstall { .. } => Method::PluginInstall, + Self::PluginUninstall { .. } => Method::PluginUninstall, + Self::PluginInstalled { .. } => Method::PluginInstalled, + Self::PluginEnabledSet { .. } => Method::PluginEnabledSet, Self::TurnStart { .. } => Method::TurnStart, Self::TurnSteer { .. } => Method::TurnSteer, Self::TurnInterrupt { .. } => Method::TurnInterrupt, diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs index 1734c273d..13ca05fa8 100644 --- a/lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs +++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs @@ -35,6 +35,12 @@ pub const METHOD_THREAD_GOAL_CLEAR: &str = "thread/goal/clear"; pub const METHOD_ARTIFACT_WRITE: &str = "artifact/write"; pub const METHOD_MEDIA_READ: &str = "media/read"; pub const METHOD_MODEL_LIST: &str = "model/list"; +pub const METHOD_PLUGIN_LIST: &str = "plugin/list"; +pub const METHOD_PLUGIN_READ: &str = "plugin/read"; +pub const METHOD_PLUGIN_INSTALL: &str = "plugin/install"; +pub const METHOD_PLUGIN_UNINSTALL: &str = "plugin/uninstall"; +pub const METHOD_PLUGIN_INSTALLED: &str = "plugin/installed"; +pub const METHOD_PLUGIN_ENABLED_SET: &str = "plugin/enabled/set"; pub const METHOD_MODEL_LIST_UPDATED: &str = "model/list/updated"; pub const METHOD_TURN_START: &str = "turn/start"; pub const METHOD_TURN_STEER: &str = "turn/steer"; @@ -153,6 +159,18 @@ pub enum Method { MediaRead, #[serde(rename = "model/list")] ModelList, + #[serde(rename = "plugin/list")] + PluginList, + #[serde(rename = "plugin/read")] + PluginRead, + #[serde(rename = "plugin/install")] + PluginInstall, + #[serde(rename = "plugin/uninstall")] + PluginUninstall, + #[serde(rename = "plugin/installed")] + PluginInstalled, + #[serde(rename = "plugin/enabled/set")] + PluginEnabledSet, #[serde(rename = "turn/start")] TurnStart, #[serde(rename = "turn/steer")] @@ -199,6 +217,12 @@ impl Method { Self::ArtifactWrite => METHOD_ARTIFACT_WRITE, Self::MediaRead => METHOD_MEDIA_READ, Self::ModelList => METHOD_MODEL_LIST, + Self::PluginList => METHOD_PLUGIN_LIST, + Self::PluginRead => METHOD_PLUGIN_READ, + Self::PluginInstall => METHOD_PLUGIN_INSTALL, + Self::PluginUninstall => METHOD_PLUGIN_UNINSTALL, + Self::PluginInstalled => METHOD_PLUGIN_INSTALLED, + Self::PluginEnabledSet => METHOD_PLUGIN_ENABLED_SET, Self::TurnStart => METHOD_TURN_START, Self::TurnSteer => METHOD_TURN_STEER, Self::TurnInterrupt => METHOD_TURN_INTERRUPT, @@ -244,6 +268,12 @@ impl Method { METHOD_ARTIFACT_WRITE => Some(Self::ArtifactWrite), METHOD_MEDIA_READ => Some(Self::MediaRead), METHOD_MODEL_LIST => Some(Self::ModelList), + METHOD_PLUGIN_LIST => Some(Self::PluginList), + METHOD_PLUGIN_READ => Some(Self::PluginRead), + METHOD_PLUGIN_INSTALL => Some(Self::PluginInstall), + METHOD_PLUGIN_UNINSTALL => Some(Self::PluginUninstall), + METHOD_PLUGIN_INSTALLED => Some(Self::PluginInstalled), + METHOD_PLUGIN_ENABLED_SET => Some(Self::PluginEnabledSet), METHOD_TURN_START => Some(Self::TurnStart), METHOD_TURN_STEER => Some(Self::TurnSteer), METHOD_TURN_INTERRUPT => Some(Self::TurnInterrupt), @@ -286,6 +316,12 @@ pub const METHODS: &[&str] = &[ METHOD_ARTIFACT_WRITE, METHOD_MEDIA_READ, METHOD_MODEL_LIST, + METHOD_PLUGIN_LIST, + METHOD_PLUGIN_READ, + METHOD_PLUGIN_INSTALL, + METHOD_PLUGIN_UNINSTALL, + METHOD_PLUGIN_INSTALLED, + METHOD_PLUGIN_ENABLED_SET, METHOD_TURN_START, METHOD_TURN_STEER, METHOD_TURN_INTERRUPT, diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/mod.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/mod.rs index 873a2d878..fb2c180ba 100644 --- a/lime-rs/crates/app-server-protocol/src/protocol/v2/mod.rs +++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/mod.rs @@ -11,6 +11,7 @@ mod media; mod methods; mod model; mod notification; +mod plugin; mod request_permissions; mod schema_types; mod thread; @@ -31,6 +32,7 @@ pub use media::*; pub use methods::*; pub use model::*; pub use notification::*; +pub use plugin::*; pub use request_permissions::*; pub use schema_types::*; pub use thread::*; diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/plugin.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/plugin.rs new file mode 100644 index 000000000..541a3ad21 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/plugin.rs @@ -0,0 +1,141 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub query: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default)] + pub marketplace_paths: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogListResponse { + pub plugins: Vec, + pub generated_at: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogInstalledParams {} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogEnabledSetParams { + pub plugin_id: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogEnabledSetResponse { + pub plugin: PluginCatalogSummary, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogSummary { + pub id: String, + pub name: String, + pub version: String, + pub marketplace_id: String, + pub content_digest: String, + pub description: String, + pub source: String, + pub source_uri: String, + pub installed: bool, + pub enabled: bool, + pub install_policy: String, + pub auth_policy: String, + pub availability: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_version: Option, + pub skills_count: u32, + pub mcp_servers_count: u32, + pub apps_count: u32, + pub hooks_count: u32, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogReadParams { + pub plugin_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogReadResponse { + pub plugin: PluginCatalogDetail, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogDetail { + pub summary: PluginCatalogSummary, + pub skills: Vec, + pub mcp_servers: Vec, + pub apps: Vec, + pub hooks: Vec, + pub ui_resources: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogCapability { + pub id: String, + pub name: String, + pub description: String, + pub requires_auth: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogHook { + pub id: String, + pub event: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogUiResource { + pub id: String, + pub resource_uri: String, + pub kind: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogInstallParams { + pub source_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub marketplace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_digest: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogInstallResponse { + pub plugin: PluginCatalogSummary, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogUninstallParams { + pub plugin_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogUninstallResponse { + pub plugin_id: String, + pub uninstalled: bool, +} diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/schema_types.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/schema_types.rs index 71859fcac..09972f4ab 100644 --- a/lime-rs/crates/app-server-protocol/src/protocol/v2/schema_types.rs +++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/schema_types.rs @@ -70,6 +70,22 @@ pub const V2_SCHEMA_TYPE_NAMES: &[&str] = &[ "Model", "ModelListResponse", "ModelListUpdatedNotification", + "PluginCatalogListParams", + "PluginCatalogInstalledParams", + "PluginCatalogEnabledSetParams", + "PluginCatalogEnabledSetResponse", + "PluginCatalogListResponse", + "PluginCatalogSummary", + "PluginCatalogReadParams", + "PluginCatalogReadResponse", + "PluginCatalogDetail", + "PluginCatalogCapability", + "PluginCatalogHook", + "PluginCatalogUiResource", + "PluginCatalogInstallParams", + "PluginCatalogInstallResponse", + "PluginCatalogUninstallParams", + "PluginCatalogUninstallResponse", "ModelRerouteReason", "ModelReroutedNotification", "ModelVerification", diff --git a/lime-rs/crates/app-server-protocol/src/schema_export/registry.rs b/lime-rs/crates/app-server-protocol/src/schema_export/registry.rs index cfec70f64..a24df8be5 100644 --- a/lime-rs/crates/app-server-protocol/src/schema_export/registry.rs +++ b/lime-rs/crates/app-server-protocol/src/schema_export/registry.rs @@ -823,6 +823,22 @@ pub(super) fn v2_schemas() -> Vec { typed_schema::("Model"), typed_schema::("ModelListResponse"), typed_schema::("ModelListUpdatedNotification"), + typed_schema::("PluginCatalogListParams"), + typed_schema::("PluginCatalogInstalledParams"), + typed_schema::("PluginCatalogEnabledSetParams"), + typed_schema::("PluginCatalogEnabledSetResponse"), + typed_schema::("PluginCatalogListResponse"), + typed_schema::("PluginCatalogSummary"), + typed_schema::("PluginCatalogReadParams"), + typed_schema::("PluginCatalogReadResponse"), + typed_schema::("PluginCatalogDetail"), + typed_schema::("PluginCatalogCapability"), + typed_schema::("PluginCatalogHook"), + typed_schema::("PluginCatalogUiResource"), + typed_schema::("PluginCatalogInstallParams"), + typed_schema::("PluginCatalogInstallResponse"), + typed_schema::("PluginCatalogUninstallParams"), + typed_schema::("PluginCatalogUninstallResponse"), typed_schema::("ModelRerouteReason"), typed_schema::("ModelReroutedNotification"), typed_schema::("ModelVerification"), diff --git a/lime-rs/crates/app-server/Cargo.toml b/lime-rs/crates/app-server/Cargo.toml index 14b56c680..28676cb26 100644 --- a/lime-rs/crates/app-server/Cargo.toml +++ b/lime-rs/crates/app-server/Cargo.toml @@ -54,6 +54,7 @@ scopeguard.workspace = true serde.workspace = true serde_json.workspace = true serde_yaml.workspace = true +semver.workspace = true sha2.workspace = true portable-pty.workspace = true patch-apply.workspace = true diff --git a/lime-rs/crates/app-server/src/local_data_source.rs b/lime-rs/crates/app-server/src/local_data_source.rs index c60e63aff..620cc1a63 100644 --- a/lime-rs/crates/app-server/src/local_data_source.rs +++ b/lime-rs/crates/app-server/src/local_data_source.rs @@ -33,6 +33,7 @@ mod mcp; mod media_tasks; mod model_projection; mod model_providers; +pub(super) mod plugin_catalog; mod plugins; mod project_materials; mod right_surface; diff --git a/lime-rs/crates/app-server/src/local_data_source/impls/mcp.rs b/lime-rs/crates/app-server/src/local_data_source/impls/mcp.rs index 5e212a7db..a14c7a22b 100644 --- a/lime-rs/crates/app-server/src/local_data_source/impls/mcp.rs +++ b/lime-rs/crates/app-server/src/local_data_source/impls/mcp.rs @@ -87,7 +87,7 @@ impl McpAppDataSource for LocalAppDataSource { async fn list_mcp_runtime_server_specs( &self, ) -> Result, RuntimeCoreError> { - mcp::list_mcp_runtime_server_specs(&self.db) + mcp::list_mcp_runtime_server_specs(&self.db, &self.plugin_data_root) } async fn list_mcp_tools_for_context( diff --git a/lime-rs/crates/app-server/src/local_data_source/impls/plugins.rs b/lime-rs/crates/app-server/src/local_data_source/impls/plugins.rs index 0b0be596b..592126659 100644 --- a/lime-rs/crates/app-server/src/local_data_source/impls/plugins.rs +++ b/lime-rs/crates/app-server/src/local_data_source/impls/plugins.rs @@ -1,4 +1,10 @@ use super::super::*; +use app_server_protocol::protocol::v2::{ + PluginCatalogEnabledSetParams, PluginCatalogEnabledSetResponse, PluginCatalogInstallParams, + PluginCatalogInstallResponse, PluginCatalogInstalledParams, PluginCatalogListParams, + PluginCatalogListResponse, PluginCatalogReadParams, PluginCatalogReadResponse, + PluginCatalogUninstallParams, PluginCatalogUninstallResponse, +}; use async_trait::async_trait; #[async_trait] @@ -7,6 +13,61 @@ impl PluginDataSource for LocalAppDataSource { Ok(self.plugin_data_root.clone()) } + async fn list_plugin_catalog( + &self, + params: PluginCatalogListParams, + ) -> Result { + crate::local_data_source::plugin_catalog::list(&self.plugin_data_root, params) + .map_err(data_error) + } + + async fn read_plugin_catalog( + &self, + params: PluginCatalogReadParams, + ) -> Result { + crate::local_data_source::plugin_catalog::read(&self.plugin_data_root, params) + .map_err(data_error) + } + + async fn install_plugin_catalog( + &self, + params: PluginCatalogInstallParams, + ) -> Result { + crate::local_data_source::plugin_catalog::install(&self.plugin_data_root, params) + .map_err(data_error) + } + + async fn uninstall_plugin_catalog( + &self, + params: PluginCatalogUninstallParams, + ) -> Result { + crate::local_data_source::plugin_catalog::uninstall(&self.plugin_data_root, params) + .map_err(data_error) + } + + async fn list_plugin_catalog_installed( + &self, + params: PluginCatalogInstalledParams, + ) -> Result { + crate::local_data_source::plugin_catalog::installed(&self.plugin_data_root, params) + .map_err(data_error) + } + + async fn list_plugin_catalog_activations(&self) -> Result, RuntimeCoreError> { + crate::local_data_source::plugin_catalog::enabled_activation_descriptors( + &self.plugin_data_root, + ) + .map_err(data_error) + } + + async fn set_plugin_catalog_enabled( + &self, + params: PluginCatalogEnabledSetParams, + ) -> Result { + crate::local_data_source::plugin_catalog::set_enabled(&self.plugin_data_root, params) + .map_err(data_error) + } + async fn list_plugin_installed(&self) -> Result { plugins::list_plugin_installed_state(&self.plugin_data_root).map_err(data_error) } diff --git a/lime-rs/crates/app-server/src/local_data_source/mcp.rs b/lime-rs/crates/app-server/src/local_data_source/mcp.rs index aac0ef5b7..3e8d2082c 100644 --- a/lime-rs/crates/app-server/src/local_data_source/mcp.rs +++ b/lime-rs/crates/app-server/src/local_data_source/mcp.rs @@ -38,6 +38,8 @@ use lime_mcp::McpServerConfig; use lime_services::mcp_service::McpService; use serde_json::json; use serde_json::Value; +use std::collections::HashSet; +use std::path::Path; pub(crate) fn list_mcp_servers( db: &DbConnection, @@ -196,8 +198,9 @@ pub(crate) async fn list_mcp_tools( pub(crate) fn list_mcp_runtime_server_specs( db: &DbConnection, + plugin_data_root: &Path, ) -> Result, RuntimeCoreError> { - Ok(McpService::get_all(db) + let mut specs = McpService::get_all(db) .map_err(data_error)? .into_iter() .filter(|server| server.enabled_lime) @@ -205,10 +208,34 @@ pub(crate) fn list_mcp_runtime_server_specs( let config = parse_mcp_server_config(&server.server_config); config.enabled.then(|| lime_mcp::McpRuntimeServerSpec { name: server.name, + plugin_id: None, config, }) }) - .collect::>()) + .collect::>(); + let mut names = specs + .iter() + .map(|spec| spec.name.clone()) + .collect::>(); + match super::plugin_catalog::list_plugin_mcp_runtime_server_specs(plugin_data_root) { + Ok(plugin_specs) => { + for spec in plugin_specs { + if !names.insert(spec.name.clone()) { + tracing::warn!( + server_name = %spec.name, + "跳过与现有 MCP server 同名的 Plugin MCP server" + ); + continue; + } + specs.push(spec); + } + } + Err(error) => { + tracing::warn!(%error, "Plugin MCP runtime 配置读取失败,跳过 Plugin MCP servers") + } + } + specs.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(specs) } pub(crate) async fn list_mcp_tools_for_context( @@ -318,6 +345,7 @@ pub(crate) async fn read_mcp_resource( mime_type: result.mime_type, text: result.text, blob: result.blob, + meta: result.meta, }) } diff --git a/lime-rs/crates/app-server/src/local_data_source/plugin_catalog.rs b/lime-rs/crates/app-server/src/local_data_source/plugin_catalog.rs new file mode 100644 index 000000000..16b9e379e --- /dev/null +++ b/lime-rs/crates/app-server/src/local_data_source/plugin_catalog.rs @@ -0,0 +1,1218 @@ +use app_server_protocol::protocol::v2::{ + PluginCatalogCapability, PluginCatalogDetail, PluginCatalogHook, PluginCatalogInstallParams, + PluginCatalogInstallResponse, PluginCatalogInstalledParams, PluginCatalogListParams, + PluginCatalogListResponse, PluginCatalogReadParams, PluginCatalogReadResponse, + PluginCatalogSummary, PluginCatalogUiResource, PluginCatalogUninstallParams, + PluginCatalogUninstallResponse, +}; +use chrono::Utc; +use lime_mcp::{McpRuntimeServerSpec, McpServerConfig, McpServerTransport}; +use semver::Version; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; + +const V2_STORE_DIR: &str = "v2"; +const PACKAGES_DIR: &str = "packages"; +const INSTALLED_DIR: &str = "installed"; +const STAGING_DIR: &str = "staging"; +const DEFAULT_MCP_CONFIG_PATH: &str = ".mcp.json"; +const INSTALLED_SCHEMA_VERSION: u64 = 1; +const MAX_MANIFEST_BYTES: u64 = 2 * 1024 * 1024; +const MAX_PACKAGE_FILES: usize = 10_000; +const MAX_PACKAGE_BYTES: u64 = 256 * 1024 * 1024; + +pub(crate) fn list( + plugin_data_root: &Path, + params: PluginCatalogListParams, +) -> Result { + let installed = read_installed_records(plugin_data_root)?; + let mut summaries = Vec::new(); + let mut source_paths = params + .marketplace_paths + .into_iter() + .map(PathBuf::from) + .collect::>(); + let explicit_source_paths = !source_paths.is_empty(); + if source_paths.is_empty() { + source_paths = implicit_marketplace_paths(plugin_data_root); + } + + for source_path in &source_paths { + for package_root in discover_package_roots(source_path)? { + let source_uri = package_root.to_string_lossy().into_owned(); + let installed_record = read_manifest(&package_root) + .ok() + .and_then(|manifest| manifest_name(&manifest).ok()) + .and_then(|plugin_id| installed.get(&plugin_id).cloned()); + let summary = build_summary( + &package_root, + source_kind_for_marketplace_path(source_path), + &source_uri, + installed_record.as_ref(), + )?; + let mut summary = summary; + if installed_record.is_none() { + summary.marketplace_id = marketplace_id_for_path(source_path) + .unwrap_or_else(|| source_kind_for_marketplace_path(source_path).to_string()); + } + if matches_query(&summary, params.query.as_deref()) + && matches_source(&summary, params.source.as_deref()) + { + summaries.push(summary); + } + } + } + + if !explicit_source_paths { + for record in installed.values() { + let package_root = installed_package_root(plugin_data_root, record)?; + let already_visible = summaries.iter().any(|summary: &PluginCatalogSummary| { + summary.id == record_string(record, "pluginId").unwrap_or_default() + && summary.version + == record_string(record, "installedVersion").unwrap_or_default() + }); + if already_visible { + continue; + } + let summary = build_summary( + &package_root, + record_string(record, "sourceKind").unwrap_or_else(|| "local".to_string()), + record_string(record, "sourceUri").unwrap_or_default(), + Some(record), + )?; + if matches_query(&summary, params.query.as_deref()) + && matches_source(&summary, params.source.as_deref()) + { + summaries.push(summary); + } + } + } + + summaries.sort_by(|left, right| left.id.cmp(&right.id)); + summaries.dedup_by(|left, right| left.id == right.id && left.version == right.version); + Ok(PluginCatalogListResponse { + plugins: summaries, + generated_at: now_iso(), + }) +} + +pub(crate) fn installed( + plugin_data_root: &Path, + _params: PluginCatalogInstalledParams, +) -> Result { + let mut summaries = Vec::new(); + for record in read_installed_records(plugin_data_root)?.values() { + let package_root = installed_package_root(plugin_data_root, record)?; + summaries.push(build_summary( + &package_root, + record_string(record, "sourceKind").unwrap_or_else(|| "local".to_string()), + record_string(record, "sourceUri").unwrap_or_default(), + Some(record), + )?); + } + summaries.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(PluginCatalogListResponse { + plugins: summaries, + generated_at: now_iso(), + }) +} + +pub(crate) fn enabled_activation_descriptors( + plugin_data_root: &Path, +) -> Result, String> { + let mut activations = Vec::new(); + for record in read_installed_records(plugin_data_root)?.values() { + if !record + .get("enabled") + .and_then(Value::as_bool) + .unwrap_or(false) + { + continue; + } + let package_root = installed_package_root(plugin_data_root, record)?; + let manifest = read_manifest(&package_root)?; + let plugin_id = manifest_name(&manifest)?; + let version = manifest_version(&manifest)?; + let runtime_capabilities = serde_json::json!({ + "schemaVersion": "plugin-runtime-capabilities/v2", + "pluginId": plugin_id.clone(), + "version": version.clone(), + "packageSourceUri": package_root.to_string_lossy(), + "skills": runtime_skill_capabilities(&package_root, &manifest)?, + "mcpServers": plugin_mcp_server_descriptors(&package_root, &manifest, &plugin_id)? + .into_iter() + .map(|server| serde_json::json!({ + "id": server.id, + "runtimeName": server.runtime_name, + "source": "plugin-v2-installed" + })) + .collect::>(), + "mcpBindings": [], + }); + activations.push(serde_json::json!({ + "schemaVersion": "plugin-activation/v2", + "pluginId": plugin_id, + "version": version, + "contentDigest": record_string(record, "contentDigest"), + "marketplaceId": record_string(record, "marketplaceId"), + "packageSourceUri": package_root.to_string_lossy(), + "sourceUri": record_string(record, "sourceUri"), + "runtimeCapabilities": runtime_capabilities, + })); + } + Ok(activations) +} + +pub(crate) fn list_plugin_mcp_runtime_server_specs( + plugin_data_root: &Path, +) -> Result, String> { + let mut specs = Vec::new(); + for record in read_installed_records(plugin_data_root)?.values() { + if !record + .get("enabled") + .and_then(Value::as_bool) + .unwrap_or(false) + { + continue; + } + + let package_root = installed_package_root(plugin_data_root, record)?; + let manifest = read_manifest(&package_root)?; + let plugin_id = manifest_name(&manifest)?; + for server in plugin_mcp_server_descriptors(&package_root, &manifest, &plugin_id)? { + specs.push(McpRuntimeServerSpec { + name: server.runtime_name, + plugin_id: Some(plugin_id.clone()), + config: server.config, + }); + } + } + specs.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(specs) +} + +struct PluginMcpServerDescriptor { + id: String, + runtime_name: String, + config: McpServerConfig, +} + +fn plugin_mcp_server_descriptors( + package_root: &Path, + manifest: &Value, + plugin_id: &str, +) -> Result, String> { + let package_root = canonical_package_root(package_root)?; + let source = match manifest.get("mcpServers") { + Some(Value::String(path)) => resource_path(&package_root, path).and_then(read_json_file), + Some(Value::Object(object)) => Ok(Value::Object(object.clone())), + Some(_) => Err("manifest.mcpServers 必须是包内路径或 object。".to_string()), + None => { + let default_path = package_root.join(DEFAULT_MCP_CONFIG_PATH); + if default_path.is_file() { + read_json_file(default_path) + } else { + return Ok(Vec::new()); + } + } + }?; + let server_values = source + .get("mcpServers") + .and_then(Value::as_object) + .or_else(|| source.as_object()) + .ok_or_else(|| "Plugin MCP 配置必须是 server object。".to_string())?; + + let mut descriptors = Vec::new(); + for (id, value) in server_values { + let runtime_name = plugin_mcp_runtime_name(plugin_id, id)?; + let mut config = match McpServerConfig::from_value(value.clone()) { + Ok(config) => config, + Err(error) => { + tracing::warn!( + plugin_id, + server_id = id, + %error, + "跳过非法 Plugin MCP server 配置" + ); + continue; + } + }; + if !config.enabled { + continue; + } + if let Err(error) = resolve_plugin_mcp_cwd(&package_root, &mut config) { + tracing::warn!( + plugin_id, + server_id = id, + %error, + "跳过 cwd 越界的 Plugin MCP server 配置" + ); + continue; + } + descriptors.push(PluginMcpServerDescriptor { + id: id.clone(), + runtime_name, + config, + }); + } + descriptors.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(descriptors) +} + +fn plugin_mcp_runtime_name(plugin_id: &str, server_id: &str) -> Result { + let server_id = server_id.trim(); + if server_id.is_empty() || server_id.len() > 96 || server_id.contains('\0') { + return Err(format!("Plugin MCP server id 非法: {server_id}")); + } + Ok(format!("plugin__{plugin_id}__{server_id}")) +} + +fn resolve_plugin_mcp_cwd(package_root: &Path, config: &mut McpServerConfig) -> Result<(), String> { + let McpServerTransport::Stdio { cwd, .. } = &mut config.transport else { + return Ok(()); + }; + let configured_cwd = cwd.clone(); + let candidate = configured_cwd + .as_deref() + .map(|value| { + let value = value.split('\0').next().unwrap_or_default().trim(); + if value.is_empty() { + package_root.to_path_buf() + } else if Path::new(value).is_absolute() { + PathBuf::from(value) + } else { + package_root.join(value) + } + }) + .unwrap_or_else(|| package_root.to_path_buf()); + let resolved = fs::canonicalize(&candidate).map_err(io_error("解析 Plugin MCP cwd 失败"))?; + if !resolved.starts_with(package_root) || !resolved.is_dir() { + return Err(format!( + "Plugin MCP cwd 必须位于 package root 内且为目录: {}", + candidate.display() + )); + } + *cwd = Some(resolved.to_string_lossy().into_owned()); + Ok(()) +} + +fn read_json_file(path: PathBuf) -> Result { + let metadata = fs::metadata(&path).map_err(io_error("读取 Plugin MCP 配置元数据失败"))?; + if metadata.len() > MAX_MANIFEST_BYTES { + return Err("Plugin MCP 配置超过大小限制。".to_string()); + } + let content = fs::read_to_string(path).map_err(io_error("读取 Plugin MCP 配置失败"))?; + serde_json::from_str(&content).map_err(|error| format!("解析 Plugin MCP 配置失败: {error}")) +} + +pub(crate) fn set_enabled( + plugin_data_root: &Path, + params: app_server_protocol::protocol::v2::PluginCatalogEnabledSetParams, +) -> Result { + validate_plugin_id(¶ms.plugin_id)?; + let records = read_installed_records(plugin_data_root)?; + let mut record = records + .get(¶ms.plugin_id) + .cloned() + .ok_or_else(|| format!("Plugin 未安装: {}", params.plugin_id))?; + record + .as_object_mut() + .ok_or_else(|| "Plugin installed record 必须是对象。".to_string())? + .insert("enabled".to_string(), Value::Bool(params.enabled)); + write_installed_record(plugin_data_root, &record)?; + let package_root = installed_package_root(plugin_data_root, &record)?; + let summary = build_summary( + &package_root, + record_string(&record, "sourceKind").unwrap_or_else(|| "local".to_string()), + record_string(&record, "sourceUri").unwrap_or_default(), + Some(&record), + )?; + Ok(app_server_protocol::protocol::v2::PluginCatalogEnabledSetResponse { plugin: summary }) +} + +pub(crate) fn read( + plugin_data_root: &Path, + params: PluginCatalogReadParams, +) -> Result { + validate_plugin_id(¶ms.plugin_id)?; + let records = read_installed_records(plugin_data_root)?; + let record = records + .get(¶ms.plugin_id) + .ok_or_else(|| format!("Plugin 未安装: {}", params.plugin_id))?; + let package_root = installed_package_root(plugin_data_root, record)?; + let summary = build_summary( + &package_root, + record_string(record, "sourceKind").unwrap_or_else(|| "local".to_string()), + record_string(record, "sourceUri").unwrap_or_default(), + Some(record), + )?; + Ok(PluginCatalogReadResponse { + plugin: build_detail(&package_root, summary)?, + }) +} + +pub(crate) fn install( + plugin_data_root: &Path, + params: PluginCatalogInstallParams, +) -> Result { + let source_root = canonical_package_root(Path::new(¶ms.source_path))?; + let store_root = plugin_data_root.join(V2_STORE_DIR); + let canonical_store = + fs::canonicalize(plugin_data_root).unwrap_or_else(|_| plugin_data_root.to_path_buf()); + if source_root.starts_with(&canonical_store) { + return Err("Plugin source 不能位于 v2 installed store 内。".to_string()); + } + let manifest = read_manifest(&source_root)?; + let plugin_id = manifest_name(&manifest)?; + let version = manifest_version(&manifest)?; + let content_digest = package_digest(&source_root)?; + if params + .expected_digest + .as_deref() + .is_some_and(|expected| expected != content_digest) + { + return Err(format!( + "Plugin package digest 不一致: expected {}, actual {content_digest}", + params.expected_digest.as_deref().unwrap_or_default() + )); + } + let installed_records = read_installed_records(plugin_data_root)?; + let previous_record = installed_records.get(&plugin_id).cloned(); + if let Some(record) = previous_record.as_ref() { + let installed_version = record_string(record, "installedVersion").unwrap_or_default(); + let installed_digest = record_string(record, "contentDigest") + .or_else(|| { + installed_package_root(plugin_data_root, record) + .ok() + .and_then(|root| package_digest(&root).ok()) + }) + .unwrap_or_default(); + if installed_version == version { + if installed_digest == content_digest { + let package_root = installed_package_root(plugin_data_root, record)?; + return Ok(PluginCatalogInstallResponse { + plugin: build_summary( + &package_root, + record_string(record, "sourceKind").unwrap_or_else(|| "local".to_string()), + record_string(record, "sourceUri").unwrap_or_default(), + Some(record), + )?, + }); + } + return Err(format!( + "Plugin package identity 冲突: {plugin_id}@{version} 的 content digest 不同" + )); + } + } + let final_root = store_root + .join(PACKAGES_DIR) + .join(&plugin_id) + .join(&version); + if final_root.exists() { + let existing_digest = package_digest(&final_root)?; + if existing_digest != content_digest { + return Err(format!( + "Plugin package 已存在且 digest 冲突: {plugin_id}@{version}" + )); + } + } + + let staging_root = store_root.join(STAGING_DIR).join(format!( + "{plugin_id}-{version}-{}", + Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + fs::create_dir_all(&staging_root).map_err(io_error("创建 Plugin staging 目录失败"))?; + if let Err(error) = copy_package_tree(&source_root, &staging_root) { + let _ = fs::remove_dir_all(&staging_root); + return Err(error); + } + let _staging_manifest = match read_manifest(&staging_root) { + Ok(manifest) => manifest, + Err(error) => { + let _ = fs::remove_dir_all(&staging_root); + return Err(format!("校验 Plugin staging manifest 失败: {error}")); + } + }; + if let Err(error) = build_summary(&staging_root, "local", ¶ms.source_path, None) { + let _ = fs::remove_dir_all(&staging_root); + return Err(format!("校验 Plugin staging capability 失败: {error}")); + } + fs::create_dir_all(final_root.parent().expect("version has parent")) + .map_err(io_error("创建 Plugin package 目录失败"))?; + if package_digest(&staging_root)? != content_digest { + let _ = fs::remove_dir_all(&staging_root); + return Err("Plugin staging digest 与 source 不一致。".to_string()); + } + if !final_root.exists() { + fs::rename(&staging_root, &final_root).map_err(io_error("提交 Plugin package 失败"))?; + } else { + fs::remove_dir_all(&staging_root).map_err(io_error("清理 Plugin staging 目录失败"))?; + } + + let package_root_locator = format!("{PACKAGES_DIR}/{plugin_id}/{version}"); + let source_kind = params.source.as_deref().unwrap_or("local"); + let marketplace_id = params.marketplace_id.as_deref().unwrap_or(source_kind); + let record = serde_json::json!({ + "schemaVersion": INSTALLED_SCHEMA_VERSION, + "pluginId": plugin_id.clone(), + "installedVersion": version.clone(), + "marketplaceId": marketplace_id, + "contentDigest": content_digest, + "sourceKind": source_kind, + "sourceUri": params.source_path, + "enabled": true, + "installedAt": now_iso(), + "packageRoot": package_root_locator, + }); + if let Err(error) = write_installed_record(plugin_data_root, &record) { + if previous_record.is_none() { + let _ = fs::remove_dir_all(&final_root); + } + return Err(error); + } + if let Some(previous_record) = previous_record.as_ref() { + if let Ok(previous_root) = installed_package_root(plugin_data_root, previous_record) { + if previous_root != final_root && previous_root.exists() { + fs::remove_dir_all(previous_root) + .map_err(io_error("清理 Plugin 旧版本 package 失败"))?; + } + } + } + let summary = build_summary( + &final_root, + "local", + record_string(&record, "sourceUri") + .as_deref() + .unwrap_or_default(), + Some(&record), + )?; + Ok(PluginCatalogInstallResponse { plugin: summary }) +} + +pub(crate) fn uninstall( + plugin_data_root: &Path, + params: PluginCatalogUninstallParams, +) -> Result { + validate_plugin_id(¶ms.plugin_id)?; + let records = read_installed_records(plugin_data_root)?; + let Some(record) = records.get(¶ms.plugin_id) else { + return Ok(PluginCatalogUninstallResponse { + plugin_id: params.plugin_id, + uninstalled: false, + }); + }; + let package_root = installed_package_root(plugin_data_root, record)?; + if package_root.exists() { + fs::remove_dir_all(&package_root).map_err(io_error("删除 Plugin package 失败"))?; + } + let record_path = installed_record_path(plugin_data_root, ¶ms.plugin_id)?; + if record_path.exists() { + fs::remove_file(record_path).map_err(io_error("删除 Plugin installed record 失败"))?; + } + Ok(PluginCatalogUninstallResponse { + plugin_id: params.plugin_id, + uninstalled: true, + }) +} + +fn build_summary, U: AsRef>( + package_root: &Path, + source: S, + source_uri: U, + installed_record: Option<&Value>, +) -> Result { + let manifest = read_manifest(package_root)?; + let plugin_id = manifest_name(&manifest)?; + let version = manifest_version(&manifest)?; + let detail = build_capability_detail(package_root, &manifest)?; + let installed = installed_record.is_some(); + let enabled = installed_record + .and_then(|record| record.get("enabled")) + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(PluginCatalogSummary { + name: interface_string(&manifest, "displayName").unwrap_or_else(|| plugin_id.clone()), + id: plugin_id, + version, + marketplace_id: installed_record + .and_then(|record| record_string(record, "marketplaceId")) + .unwrap_or_else(|| source.as_ref().to_string()), + content_digest: match installed_record + .and_then(|record| record_string(record, "contentDigest")) + { + Some(content_digest) => content_digest, + None => package_digest(package_root)?, + }, + description: interface_string(&manifest, "shortDescription") + .or_else(|| manifest_string(&manifest, "description")) + .unwrap_or_default(), + source: source.as_ref().to_string(), + source_uri: source_uri.as_ref().to_string(), + installed, + enabled, + install_policy: "AVAILABLE".to_string(), + auth_policy: "ON_USE".to_string(), + availability: if installed { "installed" } else { "available" }.to_string(), + disabled_reason: if installed && !enabled { + Some("Plugin 当前未启用。".to_string()) + } else { + None + }, + local_version: installed_record + .and_then(|record| record_string(record, "installedVersion")), + skills_count: detail.skills.len() as u32, + mcp_servers_count: detail.mcp_servers.len() as u32, + apps_count: detail.apps.len() as u32, + hooks_count: detail.hooks.len() as u32, + }) +} + +fn build_detail( + package_root: &Path, + summary: PluginCatalogSummary, +) -> Result { + let manifest = read_manifest(package_root)?; + let detail = build_capability_detail(package_root, &manifest)?; + Ok(PluginCatalogDetail { + summary, + skills: detail.skills, + mcp_servers: detail.mcp_servers, + apps: detail.apps, + hooks: detail.hooks, + ui_resources: detail.ui_resources, + }) +} + +struct CapabilityDetail { + skills: Vec, + mcp_servers: Vec, + apps: Vec, + hooks: Vec, + ui_resources: Vec, +} + +fn build_capability_detail( + package_root: &Path, + manifest: &Value, +) -> Result { + let skills = skill_capabilities(package_root, manifest)?; + let mcp_servers = object_capabilities(package_root, manifest.get("mcpServers"), "MCP")?; + let apps = object_capabilities(package_root, manifest.get("apps"), "App")?; + let hooks = hook_capabilities(package_root, manifest.get("hooks"))?; + let ui_resources = interface_resources(package_root, manifest)?; + Ok(CapabilityDetail { + skills, + mcp_servers, + apps, + hooks, + ui_resources, + }) +} + +fn skill_capabilities( + package_root: &Path, + manifest: &Value, +) -> Result, String> { + let value = manifest.get("skills"); + let Some(value) = value else { + return Ok(Vec::new()); + }; + let path = value + .as_str() + .ok_or_else(|| "manifest.skills 必须是包内相对路径。".to_string())?; + let root = resource_path(package_root, path)?; + let mut capabilities = Vec::new(); + if root.is_dir() { + for entry in fs::read_dir(&root).map_err(io_error("读取 Plugin skills 目录失败"))? { + let entry = entry.map_err(io_error("读取 Plugin skill 条目失败"))?; + let path = entry.path(); + if path.is_dir() && path.join("SKILL.md").is_file() { + let id = entry.file_name().to_string_lossy().into_owned(); + capabilities.push(PluginCatalogCapability { + id: id.clone(), + name: id, + description: String::new(), + requires_auth: false, + }); + } + } + } + capabilities.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(capabilities) +} + +fn runtime_skill_capabilities(package_root: &Path, manifest: &Value) -> Result, String> { + let Some(value) = manifest.get("skills") else { + return Ok(Vec::new()); + }; + let path = value + .as_str() + .ok_or_else(|| "manifest.skills 必须是包内相对路径。".to_string())?; + let root = resource_path(package_root, path)?; + if !root.is_dir() { + return Ok(Vec::new()); + } + let mut capabilities = Vec::new(); + for entry in fs::read_dir(&root).map_err(io_error("读取 Plugin skills 目录失败"))? { + let entry = entry.map_err(io_error("读取 Plugin skill 条目失败"))?; + let skill_root = entry.path(); + if !skill_root.is_dir() || !skill_root.join("SKILL.md").is_file() { + continue; + } + let id = entry.file_name().to_string_lossy().into_owned(); + capabilities.push(serde_json::json!({ + "id": id, + "title": id, + "path": path, + "activation": "available", + "required": false, + "promptInjectionPolicy": { + "mode": "available", + "source": "plugin-v2-installed" + } + })); + } + capabilities.sort_by(|left, right| { + left.get("id") + .and_then(Value::as_str) + .cmp(&right.get("id").and_then(Value::as_str)) + }); + Ok(capabilities) +} + +fn object_capabilities( + package_root: &Path, + value: Option<&Value>, + kind: &str, +) -> Result, String> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let object = if let Some(path) = value.as_str() { + let path = resource_path(package_root, path)?; + let content = + fs::read_to_string(path).map_err(io_error("读取 Plugin capability 文件失败"))?; + serde_json::from_str::(&content) + .map_err(|error| format!("解析 Plugin {kind} 配置失败: {error}"))? + } else { + value.clone() + }; + let object = object.get("mcpServers").unwrap_or(&object); + let Some(object) = object.as_object() else { + return Ok(Vec::new()); + }; + Ok(object + .keys() + .map(|id| PluginCatalogCapability { + id: id.clone(), + name: id.clone(), + description: String::new(), + requires_auth: false, + }) + .collect()) +} + +fn hook_capabilities( + package_root: &Path, + value: Option<&Value>, +) -> Result, String> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let value = if let Some(path) = value.as_str() { + let path = resource_path(package_root, path)?; + let content = fs::read_to_string(path).map_err(io_error("读取 Plugin hooks 配置失败"))?; + serde_json::from_str::(&content) + .map_err(|error| format!("解析 Plugin hooks 配置失败: {error}"))? + } else { + value.clone() + }; + let Some(object) = value.as_object() else { + return Ok(Vec::new()); + }; + Ok(object + .keys() + .map(|event| PluginCatalogHook { + id: event.clone(), + event: event.clone(), + }) + .collect()) +} + +fn interface_resources( + package_root: &Path, + manifest: &Value, +) -> Result, String> { + let Some(interface) = manifest.get("interface").and_then(Value::as_object) else { + return Ok(Vec::new()); + }; + let mut resources = Vec::new(); + for (key, kind) in [ + ("composerIcon", "composerIcon"), + ("logo", "logo"), + ("logoDark", "logoDark"), + ] { + if let Some(path) = interface.get(key).and_then(Value::as_str) { + let resolved = resource_path(package_root, path)?; + resources.push(PluginCatalogUiResource { + id: key.to_string(), + resource_uri: resolved + .strip_prefix(package_root) + .unwrap_or(&resolved) + .to_string_lossy() + .into_owned(), + kind: kind.to_string(), + }); + } + } + if let Some(screenshots) = interface.get("screenshots").and_then(Value::as_array) { + for (index, path) in screenshots.iter().filter_map(Value::as_str).enumerate() { + let resolved = resource_path(package_root, path)?; + resources.push(PluginCatalogUiResource { + id: format!("screenshot-{index}"), + resource_uri: resolved + .strip_prefix(package_root) + .unwrap_or(&resolved) + .to_string_lossy() + .into_owned(), + kind: "screenshot".to_string(), + }); + } + } + Ok(resources) +} + +fn discover_package_roots(path: &Path) -> Result, String> { + let path = fs::canonicalize(path).map_err(io_error("解析 Plugin catalog source 失败"))?; + if path.is_file() { + let content = fs::read_to_string(&path).map_err(io_error("读取 marketplace 文件失败"))?; + let value: Value = serde_json::from_str(&content) + .map_err(|error| format!("解析 marketplace 文件失败: {error}"))?; + let source_root = marketplace_source_root(&path)?; + let canonical_source_root = + fs::canonicalize(&source_root).map_err(io_error("解析 marketplace root 失败"))?; + let mut roots = Vec::new(); + for entry in value + .get("plugins") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(relative) = entry + .get("source") + .and_then(Value::as_object) + .and_then(|source| source.get("path")) + .and_then(Value::as_str) + else { + continue; + }; + let package_root = canonical_package_root(&source_root.join(relative))?; + if !package_root.starts_with(&canonical_source_root) { + return Err(format!("Plugin marketplace local source 越界: {relative}")); + } + roots.push(package_root); + } + return Ok(roots); + } + if path.join(".codex-plugin/plugin.json").is_file() { + return Ok(vec![path]); + } + let mut roots = Vec::new(); + for entry in fs::read_dir(&path).map_err(io_error("读取 Plugin catalog 目录失败"))? { + let entry = entry.map_err(io_error("读取 Plugin catalog 条目失败"))?; + let child = entry.path(); + if child.is_dir() && child.join(".codex-plugin/plugin.json").is_file() { + roots.push(child); + } + } + Ok(roots) +} + +fn marketplace_source_root(path: &Path) -> Result { + for relative_path in [ + ".agents/plugins/marketplace.json", + ".agents/plugins/api_marketplace.json", + ".claude-plugin/marketplace.json", + ".cursor-plugin/marketplace.json", + ] { + let relative_path = Path::new(relative_path); + if !path.ends_with(relative_path) { + continue; + } + let mut root = path.to_path_buf(); + for _ in relative_path.components() { + root.pop(); + } + return Ok(root); + } + path.parent() + .map(Path::to_path_buf) + .ok_or_else(|| "Plugin marketplace 文件缺少父目录。".to_string()) +} + +fn implicit_marketplace_paths(plugin_data_root: &Path) -> Vec { + let mut paths = Vec::new(); + if let Some(path) = bundled_marketplace_path_from_env() { + paths.push(path); + } + if let Some(home) = dirs::home_dir() { + paths.push(home.join(".agents/plugins/marketplace.json")); + } + if let Ok(current_dir) = std::env::current_dir() { + paths.push(current_dir.join(".agents/plugins/marketplace.json")); + } + let configured_root = plugin_data_root.join(V2_STORE_DIR).join("marketplaces"); + if let Ok(entries) = fs::read_dir(configured_root) { + paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| { + path.extension().and_then(|extension| extension.to_str()) == Some("json") + })); + } + paths.retain(|path| path.is_file()); + paths.sort(); + paths.dedup(); + paths +} + +fn bundled_marketplace_path_from_env() -> Option { + std::env::var_os("LIME_BUNDLED_PLUGIN_MARKETPLACE") + .map(PathBuf::from) + .filter(|path| path.is_file()) +} + +fn source_kind_for_marketplace_path(path: &Path) -> &'static str { + if bundled_marketplace_path_from_env().is_some_and(|bundled_path| path == bundled_path) { + return "bundled"; + } + if dirs::home_dir() + .map(|home| path == home.join(".agents/plugins/marketplace.json")) + .unwrap_or(false) + { + return "personal"; + } + if std::env::current_dir() + .map(|current_dir| path == current_dir.join(".agents/plugins/marketplace.json")) + .unwrap_or(false) + { + return "repo"; + } + "local" +} + +fn marketplace_id_for_path(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + let value: Value = serde_json::from_str(&content).ok()?; + value + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToString::to_string) +} + +fn read_manifest(package_root: &Path) -> Result { + let root = canonical_package_root(package_root)?; + let path = resource_path(&root, ".codex-plugin/plugin.json")?; + let metadata = fs::metadata(&path).map_err(io_error("读取 Plugin manifest 元数据失败"))?; + if metadata.len() > MAX_MANIFEST_BYTES { + return Err("Plugin manifest 超过大小限制。".to_string()); + } + let content = fs::read_to_string(path).map_err(io_error("读取 Plugin manifest 失败"))?; + let value: Value = serde_json::from_str(&content) + .map_err(|error| format!("解析 Codex Plugin manifest 失败: {error}"))?; + if value.get("schemaVersion").is_some() + || value + .get("contributions") + .and_then(Value::as_object) + .is_some_and(|contributions| { + contributions.contains_key("runtime") || contributions.contains_key("workbench") + }) + { + return Err( + "拒绝旧 Lime Plugin manifest;v2 只接受 .codex-plugin/plugin.json。".to_string(), + ); + } + manifest_name(&value)?; + manifest_version(&value)?; + Ok(value) +} + +fn manifest_name(manifest: &Value) -> Result { + let name = manifest_string(manifest, "name") + .ok_or_else(|| "Plugin manifest 缺少 name。".to_string())?; + validate_plugin_id(&name)?; + Ok(name) +} + +fn manifest_version(manifest: &Value) -> Result { + let version = manifest_string(manifest, "version") + .ok_or_else(|| "Plugin manifest 缺少 version。".to_string())?; + Version::parse(&version).map_err(|error| format!("Plugin version 不是有效 semver: {error}"))?; + Ok(version) +} + +fn package_digest(package_root: &Path) -> Result { + let package_root = canonical_package_root(package_root)?; + let mut files = Vec::new(); + collect_package_files(&package_root, &package_root, &mut files)?; + files.sort_by(|left, right| left.0.cmp(&right.0)); + let mut hasher = Sha256::new(); + for (relative, path) in files { + hasher.update((relative.len() as u64).to_be_bytes()); + hasher.update(relative.as_bytes()); + let mut file = fs::File::open(path).map_err(io_error("读取 Plugin digest 文件失败"))?; + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .map_err(io_error("计算 Plugin package digest 失败"))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + } + Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) +} + +fn collect_package_files( + package_root: &Path, + current: &Path, + files: &mut Vec<(String, PathBuf)>, +) -> Result<(), String> { + for entry in fs::read_dir(current).map_err(io_error("读取 Plugin package 目录失败"))? { + let entry = entry.map_err(io_error("读取 Plugin package 条目失败"))?; + let path = entry.path(); + let metadata = + fs::symlink_metadata(&path).map_err(io_error("读取 Plugin package 元数据失败"))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "Plugin package 禁止包含符号链接: {}", + path.display() + )); + } + if metadata.is_dir() { + collect_package_files(package_root, &path, files)?; + } else if metadata.is_file() { + let relative = path + .strip_prefix(package_root) + .map_err(|error| format!("计算 Plugin package 相对路径失败: {error}"))? + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + files.push((relative, path)); + } + } + Ok(()) +} + +fn manifest_string(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn interface_string(manifest: &Value, key: &str) -> Option { + manifest + .get("interface") + .and_then(Value::as_object) + .and_then(|interface| interface.get(key)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn validate_plugin_id(id: &str) -> Result<(), String> { + if id.is_empty() + || id.len() > 96 + || !id + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_') + || id.starts_with('-') + || id.ends_with('-') + { + return Err(format!("Plugin name 不是合法 kebab-case identity: {id}")); + } + Ok(()) +} + +fn canonical_package_root(path: &Path) -> Result { + let root = fs::canonicalize(path).map_err(io_error("解析 Plugin package root 失败"))?; + if !root.is_dir() { + return Err(format!("Plugin package root 不是目录: {}", root.display())); + } + Ok(root) +} + +fn resource_path(package_root: &Path, relative: &str) -> Result { + let relative_path = Path::new(relative); + if relative_path.is_absolute() + || relative_path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(format!("Plugin resource path 越界: {relative}")); + } + let package_root = + fs::canonicalize(package_root).map_err(io_error("解析 Plugin package root 失败"))?; + let resolved = fs::canonicalize(package_root.join(relative_path)) + .map_err(io_error("解析 Plugin resource 失败"))?; + if !resolved.starts_with(&package_root) { + return Err(format!("Plugin resource path 越界: {relative}")); + } + Ok(resolved) +} + +fn copy_package_tree(source_root: &Path, destination_root: &Path) -> Result<(), String> { + let mut files = 0usize; + let mut bytes = 0u64; + copy_package_entry(source_root, destination_root, &mut files, &mut bytes) +} + +fn copy_package_entry( + source: &Path, + destination: &Path, + files: &mut usize, + bytes: &mut u64, +) -> Result<(), String> { + let metadata = + fs::symlink_metadata(source).map_err(io_error("读取 Plugin package 条目失败"))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "Plugin package 禁止包含符号链接: {}", + source.display() + )); + } + if metadata.is_dir() { + fs::create_dir_all(destination).map_err(io_error("创建 Plugin package 条目失败"))?; + for entry in fs::read_dir(source).map_err(io_error("读取 Plugin package 目录失败"))? { + let entry = entry.map_err(io_error("读取 Plugin package 条目失败"))?; + let entry_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + copy_package_entry(&entry_path, &destination_path, files, bytes)?; + } + } else if metadata.is_file() { + *files += 1; + *bytes = bytes.saturating_add(metadata.len()); + if *files > MAX_PACKAGE_FILES || *bytes > MAX_PACKAGE_BYTES { + return Err("Plugin package 超过文件数量或总大小限制。".to_string()); + } + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(io_error("创建 Plugin package 文件目录失败"))?; + } + fs::copy(source, destination).map_err(io_error("复制 Plugin package 文件失败"))?; + } + Ok(()) +} + +fn read_installed_records( + plugin_data_root: &Path, +) -> Result, String> { + let directory = plugin_data_root.join(V2_STORE_DIR).join(INSTALLED_DIR); + fs::create_dir_all(&directory).map_err(io_error("创建 Plugin v2 installed 目录失败"))?; + let mut records = std::collections::BTreeMap::new(); + for entry in fs::read_dir(directory).map_err(io_error("读取 Plugin v2 installed 目录失败"))? + { + let entry = entry.map_err(io_error("读取 Plugin installed record 失败"))?; + if entry.path().extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let content = fs::read_to_string(entry.path()) + .map_err(io_error("读取 Plugin installed record 失败"))?; + let record: Value = serde_json::from_str(&content) + .map_err(|error| format!("解析 Plugin installed record 失败: {error}"))?; + if record.get("schemaVersion").and_then(Value::as_u64) != Some(INSTALLED_SCHEMA_VERSION) { + return Err(format!( + "不支持的 Plugin v2 installed schema: {}", + entry.path().display() + )); + } + let id = record_string(&record, "pluginId") + .ok_or_else(|| "Plugin installed record 缺少 pluginId。".to_string())?; + validate_plugin_id(&id)?; + records.insert(id, record); + } + Ok(records) +} + +fn installed_record_path(plugin_data_root: &Path, plugin_id: &str) -> Result { + validate_plugin_id(plugin_id)?; + Ok(plugin_data_root + .join(V2_STORE_DIR) + .join(INSTALLED_DIR) + .join(format!("{plugin_id}.json"))) +} + +fn installed_package_root(plugin_data_root: &Path, record: &Value) -> Result { + let relative = record_string(record, "packageRoot") + .ok_or_else(|| "Plugin installed record 缺少 packageRoot。".to_string())?; + let path = resource_path(&plugin_data_root.join(V2_STORE_DIR), &relative)?; + let packages_root = fs::canonicalize(plugin_data_root.join(V2_STORE_DIR).join(PACKAGES_DIR)) + .map_err(io_error("解析 Plugin package store 失败"))?; + if !path.starts_with(&packages_root) { + return Err("Plugin installed record 的 packageRoot 越界。".to_string()); + } + Ok(path) +} + +fn write_installed_record(plugin_data_root: &Path, record: &Value) -> Result<(), String> { + let id = record_string(record, "pluginId") + .ok_or_else(|| "Plugin installed record 缺少 pluginId。".to_string())?; + let path = installed_record_path(plugin_data_root, &id)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(io_error("创建 Plugin v2 installed 目录失败"))?; + } + let temporary = path.with_extension("json.tmp"); + fs::write( + &temporary, + serde_json::to_vec_pretty(record) + .map_err(|error| format!("序列化 Plugin installed record 失败: {error}"))?, + ) + .map_err(io_error("写入 Plugin installed record 失败"))?; + fs::rename(temporary, path).map_err(io_error("提交 Plugin installed record 失败")) +} + +fn record_string(record: &Value, key: &str) -> Option { + record + .get(key) + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn matches_query(summary: &PluginCatalogSummary, query: Option<&str>) -> bool { + let Some(query) = query.map(str::trim).filter(|query| !query.is_empty()) else { + return true; + }; + let query = query.to_ascii_lowercase(); + summary.id.to_ascii_lowercase().contains(&query) + || summary.name.to_ascii_lowercase().contains(&query) + || summary.description.to_ascii_lowercase().contains(&query) +} + +fn matches_source(summary: &PluginCatalogSummary, source: Option<&str>) -> bool { + source + .map(str::trim) + .filter(|source| !source.is_empty()) + .is_none_or(|source| summary.source == source) +} + +fn io_error(prefix: &'static str) -> impl FnOnce(std::io::Error) -> String { + move |error| format!("{prefix}: {error}") +} + +fn now_iso() -> String { + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +#[cfg(test)] +mod tests; diff --git a/lime-rs/crates/app-server/src/local_data_source/plugin_catalog/tests.rs b/lime-rs/crates/app-server/src/local_data_source/plugin_catalog/tests.rs new file mode 100644 index 000000000..7e2ae53d0 --- /dev/null +++ b/lime-rs/crates/app-server/src/local_data_source/plugin_catalog/tests.rs @@ -0,0 +1,311 @@ +use super::*; +use tempfile::TempDir; + +fn fixture(root: &Path) { + fs::create_dir_all(root.join(".codex-plugin")).unwrap(); + fs::create_dir_all(root.join("skills/example")).unwrap(); + fs::write( + root.join("skills/example/SKILL.md"), + "---\nname: example\n---\n", + ) + .unwrap(); + fs::write( + root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "example-plugin", + "version": "1.2.3", + "description": "Example", + "skills": "./skills/", + "interface": {"displayName": "Example Plugin"} + }"#, + ) + .unwrap(); +} + +fn install_params(source: &Path) -> PluginCatalogInstallParams { + PluginCatalogInstallParams { + source_path: source.to_string_lossy().into_owned(), + marketplace_id: Some("test-marketplace".to_string()), + source: Some("repo".to_string()), + expected_digest: None, + } +} + +#[test] +fn installs_codex_manifest_into_v2_store() { + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source"); + fixture(&source); + let data_root = temp.path().join("data"); + let response = install(&data_root, install_params(&source)).unwrap(); + assert_eq!(response.plugin.id, "example-plugin"); + assert_eq!(response.plugin.marketplace_id, "test-marketplace"); + assert!(response.plugin.content_digest.starts_with("sha256:")); + let detail = read( + &data_root, + PluginCatalogReadParams { + plugin_id: "example-plugin".to_string(), + }, + ) + .unwrap(); + assert_eq!(detail.plugin.skills.len(), 1); + let activations = enabled_activation_descriptors(&data_root).unwrap(); + assert_eq!(activations.len(), 1); + assert_eq!(activations[0]["pluginId"], "example-plugin"); + assert_eq!( + activations[0]["runtimeCapabilities"]["skills"][0]["id"], + "example" + ); + assert_eq!( + installed(&data_root, PluginCatalogInstalledParams {}) + .unwrap() + .plugins + .len(), + 1 + ); + let disabled = set_enabled( + &data_root, + app_server_protocol::protocol::v2::PluginCatalogEnabledSetParams { + plugin_id: "example-plugin".to_string(), + enabled: false, + }, + ) + .unwrap(); + assert!(!disabled.plugin.enabled); + assert!(enabled_activation_descriptors(&data_root) + .unwrap() + .is_empty()); + assert!( + uninstall( + &data_root, + PluginCatalogUninstallParams { + plugin_id: "example-plugin".to_string() + } + ) + .unwrap() + .uninstalled + ); +} + +#[test] +fn repeated_install_is_idempotent_but_same_version_digest_conflict_fails() { + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source"); + fixture(&source); + let data_root = temp.path().join("data"); + let first = install(&data_root, install_params(&source)).unwrap(); + let second = install(&data_root, install_params(&source)).unwrap(); + assert_eq!(first.plugin.content_digest, second.plugin.content_digest); + + fs::write(source.join("skills/example/SKILL.md"), "changed").unwrap(); + let error = install(&data_root, install_params(&source)).unwrap_err(); + assert!(error.contains("content digest 不同")); +} + +#[test] +fn expected_digest_and_semver_fail_closed() { + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source"); + fixture(&source); + let data_root = temp.path().join("data"); + let mut params = install_params(&source); + params.expected_digest = Some("sha256:wrong".to_string()); + assert!(install(&data_root, params) + .unwrap_err() + .contains("digest 不一致")); + + fs::write( + source.join(".codex-plugin/plugin.json"), + r#"{"name":"example-plugin","version":"1.2"}"#, + ) + .unwrap(); + assert!(read_manifest(&source).unwrap_err().contains("semver")); +} + +#[test] +fn rejects_legacy_manifest_and_parent_paths() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("source"); + fs::create_dir_all(root.join(".codex-plugin")).unwrap(); + fs::write( + root.join(".codex-plugin/plugin.json"), + r#"{"name":"example-plugin","version":"1.0.0","schemaVersion":"lime.plugin.package.v1"}"#, + ) + .unwrap(); + assert!(read_manifest(&root).is_err()); + assert!(resource_path(&root, "../outside").is_err()); +} + +#[test] +fn resolves_codex_marketplace_sources_from_marketplace_root() { + let temp = TempDir::new().unwrap(); + let marketplace_root = temp.path().join("openai-bundled"); + let package_root = marketplace_root.join("plugins/browser"); + fixture(&package_root); + let marketplace_path = marketplace_root.join(".agents/plugins/marketplace.json"); + fs::create_dir_all(marketplace_path.parent().unwrap()).unwrap(); + fs::write( + &marketplace_path, + r#"{ + "name": "openai-bundled", + "plugins": [{ + "name": "example-plugin", + "source": {"source": "local", "path": "./plugins/browser"} + }] + }"#, + ) + .unwrap(); + + let response = list( + &temp.path().join("data"), + PluginCatalogListParams { + marketplace_paths: vec![marketplace_path.to_string_lossy().into_owned()], + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(response.plugins.len(), 1); + assert_eq!(response.plugins[0].id, "example-plugin"); + assert_eq!(response.plugins[0].marketplace_id, "openai-bundled"); +} + +#[test] +fn rejects_marketplace_local_source_outside_marketplace_root() { + let temp = TempDir::new().unwrap(); + let marketplace_root = temp.path().join("marketplace"); + let outside = temp.path().join("outside"); + fixture(&outside); + let marketplace_path = marketplace_root.join(".agents/plugins/marketplace.json"); + fs::create_dir_all(marketplace_path.parent().unwrap()).unwrap(); + fs::write( + &marketplace_path, + r#"{ + "name": "invalid-local-source", + "plugins": [{ + "name": "example-plugin", + "source": {"source": "local", "path": "../outside"} + }] + }"#, + ) + .unwrap(); + + let error = discover_package_roots(&marketplace_path).unwrap_err(); + assert!(error.contains("local source 越界"), "{error}"); +} + +#[test] +fn loads_default_mcp_file_and_resolves_cwd_to_installed_package_root() { + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source"); + fixture(&source); + fs::create_dir_all(source.join("scripts")).unwrap(); + fs::write( + source.join(".mcp.json"), + r#"{ + "mcpServers": { + "demo": {"command": "demo-mcp", "cwd": "scripts"} + } + }"#, + ) + .unwrap(); + let data_root = temp.path().join("data"); + install(&data_root, install_params(&source)).unwrap(); + + let specs = list_plugin_mcp_runtime_server_specs(&data_root).unwrap(); + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].name, "plugin__example-plugin__demo"); + assert_eq!(specs[0].plugin_id.as_deref(), Some("example-plugin")); + assert_eq!( + specs[0].config.sanitized_cwd(), + Some(fs::canonicalize(data_root.join("v2/packages/example-plugin/1.2.3/scripts")).unwrap()) + ); + let activation = enabled_activation_descriptors(&data_root).unwrap(); + assert_eq!( + activation[0]["runtimeCapabilities"]["mcpServers"][0]["runtimeName"], + "plugin__example-plugin__demo" + ); +} + +#[test] +fn loads_inline_and_manifest_path_mcp_declarations() { + let temp = TempDir::new().unwrap(); + let inline_source = temp.path().join("inline"); + fixture(&inline_source); + fs::write( + inline_source.join(".codex-plugin/plugin.json"), + r#"{ + "name": "inline-plugin", + "version": "1.0.0", + "mcpServers": {"inline": {"command": "inline-mcp"}} + }"#, + ) + .unwrap(); + + let file_source = temp.path().join("file"); + fixture(&file_source); + fs::write( + file_source.join(".codex-plugin/plugin.json"), + r#"{ + "name": "file-plugin", + "version": "1.0.0", + "mcpServers": "./config/mcp.json" + }"#, + ) + .unwrap(); + fs::create_dir_all(file_source.join("config")).unwrap(); + fs::write( + file_source.join("config/mcp.json"), + r#"{"mcpServers":{"file":{"command":"file-mcp"}}}"#, + ) + .unwrap(); + + let data_root = temp.path().join("data"); + install(&data_root, install_params(&inline_source)).unwrap(); + install(&data_root, install_params(&file_source)).unwrap(); + let specs = list_plugin_mcp_runtime_server_specs(&data_root).unwrap(); + assert_eq!( + specs + .iter() + .map(|spec| spec.name.as_str()) + .collect::>(), + vec!["plugin__file-plugin__file", "plugin__inline-plugin__inline"] + ); +} + +#[test] +fn disabled_plugin_and_invalid_mcp_siblings_are_fail_closed() { + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source"); + fixture(&source); + fs::create_dir_all(temp.path().join("outside")).unwrap(); + fs::write( + source.join(".mcp.json"), + r#"{ + "mcpServers": { + "valid": {"command": "valid-mcp"}, + "invalid": {"url": "ftp://not-supported"}, + "escape": {"command": "escape-mcp", "cwd": "../outside"} + } + }"#, + ) + .unwrap(); + let data_root = temp.path().join("data"); + install(&data_root, install_params(&source)).unwrap(); + + let specs = list_plugin_mcp_runtime_server_specs(&data_root).unwrap(); + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].name, "plugin__example-plugin__valid"); + + set_enabled( + &data_root, + app_server_protocol::protocol::v2::PluginCatalogEnabledSetParams { + plugin_id: "example-plugin".to_string(), + enabled: false, + }, + ) + .unwrap(); + assert!(list_plugin_mcp_runtime_server_specs(&data_root) + .unwrap() + .is_empty()); +} diff --git a/lime-rs/crates/app-server/src/processor/dispatch.rs b/lime-rs/crates/app-server/src/processor/dispatch.rs index 2fed9e0c0..5a54ff787 100644 --- a/lime-rs/crates/app-server/src/processor/dispatch.rs +++ b/lime-rs/crates/app-server/src/processor/dispatch.rs @@ -502,6 +502,24 @@ impl RequestProcessor { METHOD_BROWSER_SESSION_ACTION_EXECUTE => self .handle_browser_session_action_execute_impl(params) .boxed(), + app_server_protocol::protocol::v2::METHOD_PLUGIN_LIST => { + self.handle_plugin_catalog_list_v2_impl(params).boxed() + } + app_server_protocol::protocol::v2::METHOD_PLUGIN_READ => { + self.handle_plugin_catalog_read_v2_impl(params).boxed() + } + app_server_protocol::protocol::v2::METHOD_PLUGIN_INSTALL => { + self.handle_plugin_catalog_install_v2_impl(params).boxed() + } + app_server_protocol::protocol::v2::METHOD_PLUGIN_UNINSTALL => { + self.handle_plugin_catalog_uninstall_v2_impl(params).boxed() + } + app_server_protocol::protocol::v2::METHOD_PLUGIN_INSTALLED => { + self.handle_plugin_catalog_installed_v2_impl(params).boxed() + } + app_server_protocol::protocol::v2::METHOD_PLUGIN_ENABLED_SET => self + .handle_plugin_catalog_enabled_set_v2_impl(params) + .boxed(), METHOD_PLUGIN_LOCAL_PACKAGE_INSPECT => self .handle_plugin_local_package_inspect_impl(params) .boxed(), diff --git a/lime-rs/crates/app-server/src/processor/dispatch/v2_ingress.rs b/lime-rs/crates/app-server/src/processor/dispatch/v2_ingress.rs index 0eb6ae0a0..8a06800e7 100644 --- a/lime-rs/crates/app-server/src/processor/dispatch/v2_ingress.rs +++ b/lime-rs/crates/app-server/src/processor/dispatch/v2_ingress.rs @@ -78,6 +78,14 @@ pub(super) fn into_parts( ClientRequest::ArtifactWrite { id, params } => parts(id, Method::ArtifactWrite, params), ClientRequest::MediaRead { id, params } => parts(id, Method::MediaRead, params), ClientRequest::ModelList { id, params } => parts(id, Method::ModelList, params), + ClientRequest::PluginList { id, params } => parts(id, Method::PluginList, params), + ClientRequest::PluginRead { id, params } => parts(id, Method::PluginRead, params), + ClientRequest::PluginInstall { id, params } => parts(id, Method::PluginInstall, params), + ClientRequest::PluginUninstall { id, params } => parts(id, Method::PluginUninstall, params), + ClientRequest::PluginInstalled { id, params } => parts(id, Method::PluginInstalled, params), + ClientRequest::PluginEnabledSet { id, params } => { + parts(id, Method::PluginEnabledSet, params) + } ClientRequest::ThreadSettingsUpdate { id, params } => { parts(id, Method::ThreadSettingsUpdate, params) } @@ -123,7 +131,9 @@ fn parts( mod tests { use super::*; use app_server_protocol::protocol::v2::{ - METHOD_MEDIA_READ, METHOD_THREAD_READ, METHOD_THREAD_RESUME, METHOD_TURN_INTERRUPT, + METHOD_MEDIA_READ, METHOD_PLUGIN_ENABLED_SET, METHOD_PLUGIN_INSTALL, + METHOD_PLUGIN_INSTALLED, METHOD_PLUGIN_LIST, METHOD_PLUGIN_READ, METHOD_PLUGIN_UNINSTALL, + METHOD_THREAD_READ, METHOD_THREAD_RESUME, METHOD_TURN_INTERRUPT, }; use app_server_protocol::RequestId; use serde_json::json; @@ -212,4 +222,28 @@ mod tests { let (_, method, _) = into_parts(request).expect("lower request"); assert_eq!(method, METHOD_TURN_INTERRUPT); } + + #[test] + fn plugin_v2_methods_lower_through_typed_ingress() { + for (method, params) in [ + (METHOD_PLUGIN_LIST, json!({})), + (METHOD_PLUGIN_READ, json!({ "pluginId": "demo" })), + ( + METHOD_PLUGIN_INSTALL, + json!({ "sourcePath": "/tmp/demo-plugin" }), + ), + (METHOD_PLUGIN_UNINSTALL, json!({ "pluginId": "demo" })), + (METHOD_PLUGIN_INSTALLED, json!({})), + ( + METHOD_PLUGIN_ENABLED_SET, + json!({ "pluginId": "demo", "enabled": true }), + ), + ] { + let request = decode(&request(method, params)) + .expect("decode plugin request") + .expect("plugin method is v2"); + let (_, lowered_method, _) = into_parts(request).expect("lower plugin request"); + assert_eq!(lowered_method, method); + } + } } diff --git a/lime-rs/crates/app-server/src/processor/plugin.rs b/lime-rs/crates/app-server/src/processor/plugin.rs index 6ad1b06c8..0c8ba7e8d 100644 --- a/lime-rs/crates/app-server/src/processor/plugin.rs +++ b/lime-rs/crates/app-server/src/processor/plugin.rs @@ -1,6 +1,10 @@ //! plugin domain handlers for the App Server processor. use super::{dispatch_result, parse_params, to_jsonrpc_error, RequestProcessor, RpcDispatch}; +use app_server_protocol::protocol::v2::{ + PluginCatalogEnabledSetParams, PluginCatalogInstallParams, PluginCatalogInstalledParams, + PluginCatalogListParams, PluginCatalogReadParams, PluginCatalogUninstallParams, +}; use app_server_protocol::{ JsonRpcError, PluginFetchCloudPackageParams, PluginInstalledDisabledSetParams, PluginInstalledSaveParams, PluginLocalPackageExportParams, PluginLocalPackageInspectParams, @@ -9,6 +13,90 @@ use app_server_protocol::{ }; impl RequestProcessor { + pub(super) async fn handle_plugin_catalog_list_v2_impl( + &self, + params: Option, + ) -> Result { + self.ensure_initialized()?; + let params: PluginCatalogListParams = parse_params(params)?; + let response = self + .runtime + .list_plugin_catalog(params) + .await + .map_err(to_jsonrpc_error)?; + dispatch_result(response) + } + + pub(super) async fn handle_plugin_catalog_read_v2_impl( + &self, + params: Option, + ) -> Result { + self.ensure_initialized()?; + let params: PluginCatalogReadParams = parse_params(params)?; + let response = self + .runtime + .read_plugin_catalog(params) + .await + .map_err(to_jsonrpc_error)?; + dispatch_result(response) + } + + pub(super) async fn handle_plugin_catalog_install_v2_impl( + &self, + params: Option, + ) -> Result { + self.ensure_initialized()?; + let params: PluginCatalogInstallParams = parse_params(params)?; + let response = self + .runtime + .install_plugin_catalog(params) + .await + .map_err(to_jsonrpc_error)?; + dispatch_result(response) + } + + pub(super) async fn handle_plugin_catalog_uninstall_v2_impl( + &self, + params: Option, + ) -> Result { + self.ensure_initialized()?; + let params: PluginCatalogUninstallParams = parse_params(params)?; + let response = self + .runtime + .uninstall_plugin_catalog(params) + .await + .map_err(to_jsonrpc_error)?; + dispatch_result(response) + } + + pub(super) async fn handle_plugin_catalog_installed_v2_impl( + &self, + params: Option, + ) -> Result { + self.ensure_initialized()?; + let params: PluginCatalogInstalledParams = parse_params(params)?; + let response = self + .runtime + .list_plugin_catalog_installed(params) + .await + .map_err(to_jsonrpc_error)?; + dispatch_result(response) + } + + pub(super) async fn handle_plugin_catalog_enabled_set_v2_impl( + &self, + params: Option, + ) -> Result { + self.ensure_initialized()?; + let params: PluginCatalogEnabledSetParams = parse_params(params)?; + let response = self + .runtime + .set_plugin_catalog_enabled(params) + .await + .map_err(to_jsonrpc_error)?; + dispatch_result(response) + } + pub(super) async fn handle_plugin_installed_list_impl( &self, ) -> Result { diff --git a/lime-rs/crates/app-server/src/processor/thread/projection.rs b/lime-rs/crates/app-server/src/processor/thread/projection.rs index 0ee919d3e..3e2442328 100644 --- a/lime-rs/crates/app-server/src/processor/thread/projection.rs +++ b/lime-rs/crates/app-server/src/processor/thread/projection.rs @@ -408,6 +408,8 @@ fn project_item(item: canonical::ThreadItem) -> Result Result Result { + Err(RuntimeCoreError::Backend( + "runtime backend does not expose MCP resources".to_string(), + )) + } + async fn prepare_runtime_worker_artifact_events( &self, _request: &ExecutionRequest, diff --git a/lime-rs/crates/app-server/src/runtime/app_data/plugins.rs b/lime-rs/crates/app-server/src/runtime/app_data/plugins.rs index 88389e6b3..882e5baf6 100644 --- a/lime-rs/crates/app-server/src/runtime/app_data/plugins.rs +++ b/lime-rs/crates/app-server/src/runtime/app_data/plugins.rs @@ -1,8 +1,15 @@ use super::unavailable; use super::NoopAppDataSource; use super::RuntimeCoreError; +use app_server_protocol::protocol::v2::{ + PluginCatalogEnabledSetParams, PluginCatalogEnabledSetResponse, PluginCatalogInstallParams, + PluginCatalogInstallResponse, PluginCatalogInstalledParams, PluginCatalogListParams, + PluginCatalogListResponse, PluginCatalogReadParams, PluginCatalogReadResponse, + PluginCatalogUninstallParams, PluginCatalogUninstallResponse, +}; use app_server_protocol::*; use async_trait::async_trait; +use serde_json::Value; use std::path::PathBuf; #[async_trait] @@ -11,6 +18,52 @@ pub trait PluginDataSource: Send + Sync { Err(unavailable("pluginData/root")) } + async fn list_plugin_catalog( + &self, + _params: PluginCatalogListParams, + ) -> Result { + Err(unavailable("plugin/list")) + } + + async fn read_plugin_catalog( + &self, + _params: PluginCatalogReadParams, + ) -> Result { + Err(unavailable("plugin/read")) + } + + async fn install_plugin_catalog( + &self, + _params: PluginCatalogInstallParams, + ) -> Result { + Err(unavailable("plugin/install")) + } + + async fn uninstall_plugin_catalog( + &self, + _params: PluginCatalogUninstallParams, + ) -> Result { + Err(unavailable("plugin/uninstall")) + } + + async fn list_plugin_catalog_installed( + &self, + _params: PluginCatalogInstalledParams, + ) -> Result { + Err(unavailable("plugin/installed")) + } + + async fn list_plugin_catalog_activations(&self) -> Result, RuntimeCoreError> { + Ok(Vec::new()) + } + + async fn set_plugin_catalog_enabled( + &self, + _params: PluginCatalogEnabledSetParams, + ) -> Result { + Err(unavailable("plugin/enabled/set")) + } + async fn list_plugin_installed(&self) -> Result { Ok(PluginInstalledListResponse::default()) } diff --git a/lime-rs/crates/app-server/src/runtime/canonical_thread_store.rs b/lime-rs/crates/app-server/src/runtime/canonical_thread_store.rs index 6b0c7c665..5406a92e0 100644 --- a/lime-rs/crates/app-server/src/runtime/canonical_thread_store.rs +++ b/lime-rs/crates/app-server/src/runtime/canonical_thread_store.rs @@ -1,5 +1,6 @@ use std::any::Any; use std::collections::{HashMap, HashSet}; +use std::time::Duration; use agent_protocol::{ CollabAgentState, CollabAgentStatus, SortDirection, Thread, ThreadActiveFlag, @@ -48,6 +49,7 @@ use queries::{ }; const MAX_PAGE_SIZE: u32 = 500; +const THREAD_STORE_BUSY_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -374,6 +376,8 @@ impl ProjectionStore { pub(super) fn open_thread_store(&self) -> ThreadStoreResult { let conn = Connection::open(self.state_path()).map_err(store_error)?; + conn.busy_timeout(THREAD_STORE_BUSY_TIMEOUT) + .map_err(store_error)?; conn.execute_batch("PRAGMA foreign_keys = ON;") .map_err(store_error)?; if self.state_path() != self.thread_history_path() { diff --git a/lime-rs/crates/app-server/src/runtime/canonical_thread_store/persistence.rs b/lime-rs/crates/app-server/src/runtime/canonical_thread_store/persistence.rs index 9e3c77f51..2536c1d9b 100644 --- a/lime-rs/crates/app-server/src/runtime/canonical_thread_store/persistence.rs +++ b/lime-rs/crates/app-server/src/runtime/canonical_thread_store/persistence.rs @@ -173,7 +173,7 @@ pub(super) fn apply_change_set( upsert_turn(tx, turn, params.changes.sequence)?; } for item in ¶ms.changes.changed_items { - upsert_item(tx, item)?; + upsert_item_snapshot(tx, item)?; } Ok(()) } @@ -221,7 +221,7 @@ fn upsert_turn(tx: &Transaction<'_>, turn: &Turn, sequence: u64) -> ThreadStoreR Ok(()) } -fn upsert_item(tx: &Transaction<'_>, item: &ThreadItem) -> ThreadStoreResult<()> { +fn upsert_item_snapshot(tx: &Transaction<'_>, item: &ThreadItem) -> ThreadStoreResult<()> { if item.kind != item.payload_kind() { return Err(error(format!( "item {} kind does not match payload", @@ -230,24 +230,20 @@ fn upsert_item(tx: &Transaction<'_>, item: &ThreadItem) -> ThreadStoreResult<()> } let existing = tx .query_row( - "SELECT turn_id, item_json FROM canonical_items WHERE thread_id = ?1 AND item_id = ?2", + "SELECT turn_id FROM canonical_items WHERE thread_id = ?1 AND item_id = ?2", params![item.thread_id.as_str(), item.item_id.as_str()], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + |row| row.get::<_, String>(0), ) .optional() .map_err(store_error)?; - let snapshot = if let Some((existing_turn_id, json)) = existing { + if let Some(existing_turn_id) = existing { if existing_turn_id != item.turn_id.as_str() { return Err(error(format!( "item {} changed turn identity", item.item_id ))); } - let previous = decode_json(&json)?; - super::super::thread_item_projection::merge_item_snapshot(previous, item.clone()) - } else { - item.clone() - }; + } tx.execute( "INSERT INTO canonical_items ( thread_id, turn_id, item_id, ordinal, sequence, item_json @@ -257,12 +253,12 @@ fn upsert_item(tx: &Transaction<'_>, item: &ThreadItem) -> ThreadStoreResult<()> sequence = excluded.sequence, item_json = excluded.item_json", params![ - snapshot.thread_id.as_str(), - snapshot.turn_id.as_str(), - snapshot.item_id.as_str(), - to_i64(snapshot.ordinal, "item ordinal")?, - to_i64(snapshot.sequence, "item sequence")?, - encode_json(&snapshot)?, + item.thread_id.as_str(), + item.turn_id.as_str(), + item.item_id.as_str(), + to_i64(item.ordinal, "item ordinal")?, + to_i64(item.sequence, "item sequence")?, + encode_json(item)?, ], ) .map_err(|source| error(format!("cannot persist canonical item: {source}")))?; diff --git a/lime-rs/crates/app-server/src/runtime/canonical_thread_store_tests.rs b/lime-rs/crates/app-server/src/runtime/canonical_thread_store_tests.rs index 0906b8991..a3a735753 100644 --- a/lime-rs/crates/app-server/src/runtime/canonical_thread_store_tests.rs +++ b/lime-rs/crates/app-server/src/runtime/canonical_thread_store_tests.rs @@ -101,6 +101,17 @@ fn production_storage_paths_keep_physical_table_owners_separate() { ); } +#[test] +fn canonical_store_connections_wait_for_transient_sqlite_locks() { + let (_temp, store) = store(); + let conn = store.open_thread_store().expect("open canonical store"); + let busy_timeout_ms = conn + .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, u64>(0)) + .expect("read SQLite busy timeout"); + + assert_eq!(busy_timeout_ms, 5_000); +} + #[test] fn queued_recovery_requires_a_current_canonical_thread_owner() { let temp = tempfile::tempdir().expect("tempdir"); @@ -1835,6 +1846,190 @@ fn production_event_batches_create_and_incrementally_update_canonical_history() )); } +#[test] +fn reasoning_deltas_persist_linearly_in_canonical_history_and_rollout() { + let (_temp, agent_root, store) = store_with_rollout(); + let stored = StoredSession { + session: AgentSession { + session_id: "session-reasoning-linear".to_string(), + thread_id: "thread-reasoning-linear".to_string(), + app_id: "agent-chat".to_string(), + workspace_id: None, + business_object_ref: None, + status: AgentSessionStatus::Running, + created_at: "2026-08-05T00:00:00Z".to_string(), + updated_at: "2026-08-05T00:00:01Z".to_string(), + }, + turns: Vec::new(), + turn_inputs: HashMap::new(), + turn_runtime_options: HashMap::new(), + events: Vec::new(), + output_blobs: HashMap::new(), + }; + let fragments = [ + "The", " user", " said", " hello", ".", " Respond", " briefly", " now", + ]; + + for (index, fragment) in fragments.iter().enumerate() { + let sequence = (index + 1) as u64; + store + .apply_canonical_events( + &stored, + &[AgentEvent { + event_id: format!("reasoning-event-{sequence}"), + sequence, + session_id: stored.session.session_id.clone(), + thread_id: Some(stored.session.thread_id.clone()), + turn_id: Some("turn-reasoning-linear".to_string()), + event_type: "reasoning.delta".to_string(), + timestamp: format!("2026-08-05T00:00:{sequence:02}Z"), + payload: json!({ + "reasoningId": "reasoning-linear", + "delta": fragment, + }), + }], + ) + .expect("persist reasoning delta"); + } + + let thread = block_on(store.read_thread(ReadThreadParams { + thread_id: ThreadId::new(&stored.session.thread_id), + include_archived: false, + turns_view: ThreadTurnsView::Full, + })) + .expect("read canonical reasoning thread") + .expect("canonical reasoning thread"); + let reasoning_content = thread.turns[0] + .items + .iter() + .find_map(|item| match &item.payload { + ThreadItemPayload::Reasoning { content, .. } => Some(content.clone()), + _ => None, + }) + .expect("canonical reasoning item"); + assert_eq!(reasoning_content, fragments.map(str::to_string).to_vec()); + + let path = rollout_path( + &store, + &agent_root, + &ThreadId::new(&stored.session.thread_id), + ); + let lines = rollout_lines(&path); + assert_eq!(lines.len(), fragments.len() + 1); + for (index, line) in lines.iter().skip(1).enumerate() { + let changes: ThreadHistoryChangeSet = serde_json::from_value(line["changes"].clone()) + .expect("decode rollout history changes"); + let content = changes + .changed_items + .iter() + .find_map(|item| match &item.payload { + ThreadItemPayload::Reasoning { content, .. } => Some(content), + _ => None, + }) + .expect("rollout reasoning item"); + assert_eq!(content.len(), index + 1); + assert_eq!(content.last().map(String::as_str), Some(fragments[index])); + } +} + +#[test] +fn empty_reasoning_completion_preserves_canonical_content_across_persistence() { + let (_temp, store) = store(); + let stored = StoredSession { + session: AgentSession { + session_id: "session-reasoning-empty-completion".to_string(), + thread_id: "thread-reasoning-empty-completion".to_string(), + app_id: "agent-chat".to_string(), + workspace_id: None, + business_object_ref: None, + status: AgentSessionStatus::Running, + created_at: "2026-08-05T00:00:00Z".to_string(), + updated_at: "2026-08-05T00:00:01Z".to_string(), + }, + turns: Vec::new(), + turn_inputs: HashMap::new(), + turn_runtime_options: HashMap::new(), + events: Vec::new(), + output_blobs: HashMap::new(), + }; + let reasoning_item_id = "item_reasoning-turn-reasoning-empty-completion"; + let event = |sequence, event_type: &str, payload| AgentEvent { + event_id: format!("reasoning-empty-event-{sequence}"), + sequence, + session_id: stored.session.session_id.clone(), + thread_id: Some(stored.session.thread_id.clone()), + turn_id: Some("turn-reasoning-empty-completion".to_string()), + event_type: event_type.to_string(), + timestamp: format!("2026-08-05T00:00:{sequence:02}Z"), + payload, + }; + + store + .apply_canonical_events( + &stored, + &[event( + 1, + "reasoning.delta", + json!({ + "reasoningId": "reasoning-empty-completion", + "text": "inspect inputs" + }), + )], + ) + .expect("persist reasoning content"); + store + .apply_canonical_events( + &stored, + &[event( + 2, + "item.completed", + json!({ + "item": { + "sessionId": stored.session.session_id.clone(), + "threadId": stored.session.thread_id.clone(), + "turnId": "turn-reasoning-empty-completion", + "itemId": reasoning_item_id, + "sequence": 2, + "ordinal": 2, + "createdAtMs": 2, + "updatedAtMs": 2, + "completedAtMs": 2, + "kind": "reasoning", + "status": "completed", + "payload": { + "type": "reasoning", + "summary": [], + "content": [] + }, + "metadata": {} + } + }), + )], + ) + .expect("persist empty reasoning completion"); + + let thread = block_on(store.read_thread(ReadThreadParams { + thread_id: ThreadId::new(&stored.session.thread_id), + include_archived: false, + turns_view: ThreadTurnsView::Full, + })) + .expect("read persisted reasoning thread") + .expect("persisted reasoning thread"); + let reasoning = thread.turns[0] + .items + .iter() + .find(|item| matches!(item.payload, ThreadItemPayload::Reasoning { .. })) + .expect("persisted reasoning item"); + assert_eq!(reasoning.status, ItemStatus::Completed); + assert_eq!( + reasoning.payload, + ThreadItemPayload::Reasoning { + summary: Vec::new(), + content: vec!["inspect inputs".to_string()], + } + ); +} + #[test] fn canonical_live_cold_and_replay_projections_are_equivalent() { let stored = StoredSession { diff --git a/lime-rs/crates/app-server/src/runtime/conversation_import/codex/canonical_items.rs b/lime-rs/crates/app-server/src/runtime/conversation_import/codex/canonical_items.rs index d21350ba4..971d9c2af 100644 --- a/lime-rs/crates/app-server/src/runtime/conversation_import/codex/canonical_items.rs +++ b/lime-rs/crates/app-server/src/runtime/conversation_import/codex/canonical_items.rs @@ -458,6 +458,8 @@ fn canonical_tool_payload( call_id: call_id.to_string(), server_name, tool_name: mcp_tool_name, + mcp_app_resource_uri: None, + plugin_id: None, arguments, output, }, diff --git a/lime-rs/crates/app-server/src/runtime/evidence_provider/canonical_tool.rs b/lime-rs/crates/app-server/src/runtime/evidence_provider/canonical_tool.rs index 75a4332c7..4010fdb63 100644 --- a/lime-rs/crates/app-server/src/runtime/evidence_provider/canonical_tool.rs +++ b/lime-rs/crates/app-server/src/runtime/evidence_provider/canonical_tool.rs @@ -97,6 +97,7 @@ pub(super) fn canonical_tool(event: &AgentEvent) -> Option { tool_name, arguments, output, + .. } => ( call_id, tool_name, diff --git a/lime-rs/crates/app-server/src/runtime/mcp.rs b/lime-rs/crates/app-server/src/runtime/mcp.rs index 4ba0dfeb4..6853c8aff 100644 --- a/lime-rs/crates/app-server/src/runtime/mcp.rs +++ b/lime-rs/crates/app-server/src/runtime/mcp.rs @@ -129,7 +129,17 @@ impl RuntimeCore { &self, params: McpResourceReadParams, ) -> Result { - self.app_data_source.read_mcp_resource(params).await + match (¶ms.session_id, ¶ms.thread_id) { + (None, None) => self.app_data_source.read_mcp_resource(params).await, + (Some(session_id), Some(thread_id)) + if !session_id.trim().is_empty() && !thread_id.trim().is_empty() => + { + self.backend.read_mcp_runtime_resource(params).await + } + _ => Err(RuntimeCoreError::InvalidRequest( + "mcpResource/read sessionId and threadId must be provided together".to_string(), + )), + } } pub async fn subscribe_mcp_resource( diff --git a/lime-rs/crates/app-server/src/runtime/output_refs/tests.rs b/lime-rs/crates/app-server/src/runtime/output_refs/tests.rs index 7e9cac238..f824607ec 100644 --- a/lime-rs/crates/app-server/src/runtime/output_refs/tests.rs +++ b/lime-rs/crates/app-server/src/runtime/output_refs/tests.rs @@ -38,6 +38,8 @@ fn canonical_tool_payload( call_id: "call-mcp-large".to_string(), server_name: "files".to_string(), tool_name: "read_resource".to_string(), + mcp_app_resource_uri: None, + plugin_id: None, arguments: Vec::new(), output, }, diff --git a/lime-rs/crates/app-server/src/runtime/plugins.rs b/lime-rs/crates/app-server/src/runtime/plugins.rs index edf3701f4..ccd9e286f 100644 --- a/lime-rs/crates/app-server/src/runtime/plugins.rs +++ b/lime-rs/crates/app-server/src/runtime/plugins.rs @@ -6,6 +6,17 @@ use super::plugin_task_runtime::{ use super::timestamp; use super::RuntimeCore; use super::RuntimeCoreError; +use app_server_protocol::protocol::v2::PluginCatalogEnabledSetParams; +use app_server_protocol::protocol::v2::PluginCatalogEnabledSetResponse; +use app_server_protocol::protocol::v2::PluginCatalogInstallParams; +use app_server_protocol::protocol::v2::PluginCatalogInstallResponse; +use app_server_protocol::protocol::v2::PluginCatalogInstalledParams; +use app_server_protocol::protocol::v2::PluginCatalogListParams; +use app_server_protocol::protocol::v2::PluginCatalogListResponse; +use app_server_protocol::protocol::v2::PluginCatalogReadParams; +use app_server_protocol::protocol::v2::PluginCatalogReadResponse; +use app_server_protocol::protocol::v2::PluginCatalogUninstallParams; +use app_server_protocol::protocol::v2::PluginCatalogUninstallResponse; use app_server_protocol::PluginFetchCloudPackageParams; use app_server_protocol::PluginInstalledDisabledSetParams; use app_server_protocol::PluginInstalledListResponse; @@ -70,6 +81,52 @@ struct PluginShellDescriptorFields { } impl RuntimeCore { + pub async fn list_plugin_catalog( + &self, + params: PluginCatalogListParams, + ) -> Result { + self.app_data_source.list_plugin_catalog(params).await + } + + pub async fn read_plugin_catalog( + &self, + params: PluginCatalogReadParams, + ) -> Result { + self.app_data_source.read_plugin_catalog(params).await + } + + pub async fn install_plugin_catalog( + &self, + params: PluginCatalogInstallParams, + ) -> Result { + self.app_data_source.install_plugin_catalog(params).await + } + + pub async fn uninstall_plugin_catalog( + &self, + params: PluginCatalogUninstallParams, + ) -> Result { + self.app_data_source.uninstall_plugin_catalog(params).await + } + + pub async fn list_plugin_catalog_installed( + &self, + params: PluginCatalogInstalledParams, + ) -> Result { + self.app_data_source + .list_plugin_catalog_installed(params) + .await + } + + pub async fn set_plugin_catalog_enabled( + &self, + params: PluginCatalogEnabledSetParams, + ) -> Result { + self.app_data_source + .set_plugin_catalog_enabled(params) + .await + } + pub(super) fn installed_plugin_runtime_dir( &self, state: &serde_json::Value, diff --git a/lime-rs/crates/app-server/src/runtime/provider_history/canonical.rs b/lime-rs/crates/app-server/src/runtime/provider_history/canonical.rs index c67a4cfb6..92267f446 100644 --- a/lime-rs/crates/app-server/src/runtime/provider_history/canonical.rs +++ b/lime-rs/crates/app-server/src/runtime/provider_history/canonical.rs @@ -342,6 +342,8 @@ mod tests { call_id: "mcp-call".to_string(), server_name: "docs".to_string(), tool_name: tool_name.to_string(), + mcp_app_resource_uri: None, + plugin_id: None, arguments: vec![ToolArgument { name: "query".to_string(), value: "\"fork\"".to_string(), diff --git a/lime-rs/crates/app-server/src/runtime/read_model/canonical_items.rs b/lime-rs/crates/app-server/src/runtime/read_model/canonical_items.rs index 05b143f4e..e572c5863 100644 --- a/lime-rs/crates/app-server/src/runtime/read_model/canonical_items.rs +++ b/lime-rs/crates/app-server/src/runtime/read_model/canonical_items.rs @@ -252,6 +252,8 @@ fn canonical_payload_to_agent_detail( call_id, server_name, tool_name, + mcp_app_resource_uri, + plugin_id, arguments, output, } => { @@ -259,6 +261,12 @@ fn canonical_payload_to_agent_detail( detail.insert("mcp_server".to_string(), json!(server_name)); detail.insert("tool_name".to_string(), json!(tool_name)); detail.insert("arguments".to_string(), json!(arguments)); + if let Some(resource_uri) = mcp_app_resource_uri { + detail.insert("mcp_app_resource_uri".to_string(), json!(resource_uri)); + } + if let Some(plugin_id) = plugin_id { + detail.insert("plugin_id".to_string(), json!(plugin_id)); + } if let Some(output) = output { insert_tool_output(&mut detail, output); } diff --git a/lime-rs/crates/app-server/src/runtime/tests/agent_control/fork.rs b/lime-rs/crates/app-server/src/runtime/tests/agent_control/fork.rs index 9603aeba7..976748652 100644 --- a/lime-rs/crates/app-server/src/runtime/tests/agent_control/fork.rs +++ b/lime-rs/crates/app-server/src/runtime/tests/agent_control/fork.rs @@ -173,6 +173,8 @@ async fn fork_uses_codex_sanitize_profile_for_internal_items() { call_id: "mcp-call".to_string(), server_name: "docs".to_string(), tool_name: "search".to_string(), + mcp_app_resource_uri: None, + plugin_id: None, arguments: Vec::new(), output: Some(ToolOutput { text: Some("mcp output".to_string()), diff --git a/lime-rs/crates/app-server/src/runtime/thread_item_projection.rs b/lime-rs/crates/app-server/src/runtime/thread_item_projection.rs index 56a86b399..25cee7e6e 100644 --- a/lime-rs/crates/app-server/src/runtime/thread_item_projection.rs +++ b/lime-rs/crates/app-server/src/runtime/thread_item_projection.rs @@ -7,7 +7,7 @@ pub(super) mod materializer; pub(in crate::runtime) mod media_result; mod plan; -pub(in crate::runtime) use change_set::{merge_item_snapshot, merge_turn_snapshot}; +pub(in crate::runtime) use change_set::merge_turn_snapshot; pub(in crate::runtime) use materializer::{materialize_events, IncrementalMaterializer}; #[cfg(test)] diff --git a/lime-rs/crates/app-server/src/runtime/thread_item_projection/change_set.rs b/lime-rs/crates/app-server/src/runtime/thread_item_projection/change_set.rs index 5a56f6783..1d10fda14 100644 --- a/lime-rs/crates/app-server/src/runtime/thread_item_projection/change_set.rs +++ b/lime-rs/crates/app-server/src/runtime/thread_item_projection/change_set.rs @@ -323,8 +323,16 @@ fn merge_payload( ) => match source_event_type { Some("reasoning.final" | "item.completed" | "item.updated") => { ThreadItemPayload::Reasoning { - summary: next_summary, - content: next_content, + summary: if next_summary.is_empty() { + summary + } else { + next_summary + }, + content: if next_content.is_empty() { + content + } else { + next_content + }, } } _ => { @@ -365,6 +373,8 @@ fn merge_payload( call_id: previous_call_id, server_name: previous_server_name, tool_name: previous_tool_name, + mcp_app_resource_uri: previous_mcp_app_resource_uri, + plugin_id: previous_plugin_id, arguments: previous_arguments, output: previous_output, }, @@ -372,6 +382,8 @@ fn merge_payload( call_id, server_name, tool_name, + mcp_app_resource_uri, + plugin_id, arguments, output, }, @@ -379,6 +391,8 @@ fn merge_payload( call_id: prefer_string(previous_call_id, call_id, ""), server_name: prefer_string(previous_server_name, server_name, "unknown"), tool_name: prefer_string(previous_tool_name, tool_name, "tool"), + mcp_app_resource_uri: mcp_app_resource_uri.or(previous_mcp_app_resource_uri), + plugin_id: plugin_id.or(previous_plugin_id), arguments: if arguments.is_empty() { previous_arguments } else { diff --git a/lime-rs/crates/app-server/src/runtime/thread_item_projection/materializer/lowering.rs b/lime-rs/crates/app-server/src/runtime/thread_item_projection/materializer/lowering.rs index 34d428934..a88cafdf6 100644 --- a/lime-rs/crates/app-server/src/runtime/thread_item_projection/materializer/lowering.rs +++ b/lime-rs/crates/app-server/src/runtime/thread_item_projection/materializer/lowering.rs @@ -244,6 +244,11 @@ pub(super) fn typed_payload( .unwrap_or_else(|| "unknown".to_string()), tool_name: map_string(payload, &["toolName", "tool_name", "name"]) .unwrap_or_else(|| "tool".to_string()), + mcp_app_resource_uri: map_string( + payload, + &["mcpAppResourceUri", "mcp_app_resource_uri"], + ), + plugin_id: map_string(payload, &["pluginId", "plugin_id"]), arguments: tool_arguments(payload), output: tool_output(payload), }, diff --git a/lime-rs/crates/app-server/src/runtime/thread_item_projection/typed_tests/incremental.rs b/lime-rs/crates/app-server/src/runtime/thread_item_projection/typed_tests/incremental.rs index 99babc170..988361504 100644 --- a/lime-rs/crates/app-server/src/runtime/thread_item_projection/typed_tests/incremental.rs +++ b/lime-rs/crates/app-server/src/runtime/thread_item_projection/typed_tests/incremental.rs @@ -106,6 +106,97 @@ fn reasoning_deltas_preserve_repeated_fragments_and_final_snapshot() { ); } +#[test] +fn empty_reasoning_completion_preserves_materialized_content() { + let delta = materialize_events( + &[ + event( + "reasoning-started", + 1, + "reasoning.started", + "turn-1", + json!({"reasoningId": "reasoning-1"}), + ), + event( + "reasoning-delta", + 2, + "reasoning.delta", + "turn-1", + json!({"reasoningId": "reasoning-1", "text": "inspect inputs"}), + ), + ], + "session-1", + "thread-1", + ) + .expect("materialize reasoning delta"); + let mut completed_item = delta + .changed_items + .into_iter() + .find(|item| { + matches!( + item.payload, + agent_protocol::ThreadItemPayload::Reasoning { .. } + ) + }) + .expect("reasoning delta item"); + completed_item.status = agent_protocol::ItemStatus::Completed; + completed_item.completed_at_ms = Some(3); + completed_item.sequence = 3; + completed_item.updated_at_ms = 3; + completed_item.metadata = json!({"source_event_type": "item.completed"}); + completed_item.payload = agent_protocol::ThreadItemPayload::Reasoning { + summary: Vec::new(), + content: Vec::new(), + }; + + let changes = materialize_events( + &[ + event( + "reasoning-started", + 1, + "reasoning.started", + "turn-1", + json!({"reasoningId": "reasoning-1"}), + ), + event( + "reasoning-delta", + 2, + "reasoning.delta", + "turn-1", + json!({"reasoningId": "reasoning-1", "text": "inspect inputs"}), + ), + event( + "reasoning-completed", + 3, + "item.completed", + "turn-1", + json!({"item": serde_json::to_value(completed_item).expect("serialize item")}), + ), + ], + "session-1", + "thread-1", + ) + .expect("materialize empty reasoning completion"); + let reasoning = changes + .changed_items + .iter() + .find(|item| { + matches!( + item.payload, + agent_protocol::ThreadItemPayload::Reasoning { .. } + ) + }) + .expect("completed reasoning item"); + assert_eq!(reasoning.status, agent_protocol::ItemStatus::Completed); + assert_eq!( + reasoning.payload, + agent_protocol::ThreadItemPayload::Reasoning { + summary: Vec::new(), + content: vec!["inspect inputs".to_string()], + } + ); +} + #[test] fn incremental_materializer_does_not_revive_removed_item_identity() { let started = event( diff --git a/lime-rs/crates/app-server/src/runtime_backend.rs b/lime-rs/crates/app-server/src/runtime_backend.rs index 2db22b8c2..53c1212a9 100644 --- a/lime-rs/crates/app-server/src/runtime_backend.rs +++ b/lime-rs/crates/app-server/src/runtime_backend.rs @@ -65,11 +65,14 @@ mod request_context; pub(crate) use model_routing::configured_provider_readiness; pub(crate) use provider_config::current_agent_runtime_config_metadata; use provider_config::{initialize_runtime_database, model_effective_event_from_runtime}; +#[cfg(test)] +use request_context::session_config_from_request; use request_context::{ apply_app_server_turn_policy, direct_provider_config_from_request, request_tool_policy_from_request, resolve_runtime_model_selection, - runtime_request_from_request, service_tier_from_request, session_config_from_request, - session_scope_from_request, should_use_compact_tool_surface, + runtime_request_from_request, service_tier_from_request, + session_config_from_request_with_plugin_activations, session_scope_from_request, + should_use_compact_tool_surface, }; use route_support::{ agent_control_route_snapshot_for_resolved_route, durable_credential_ref_for_generation, @@ -418,8 +421,15 @@ impl RuntimeBackend { let soul_style = tool_process_metadata::SoulStyleMetadata::from_config_metadata( config_metadata.as_ref(), ); - let mention_selection = - mention_selection::resolve_mentions(&request, self.current_app_data_source()?).await; + let plugin_activations = self.current_plugin_activations().await?; + let mention_selection = mention_selection::resolve_mentions( + &request, + self.current_app_data_source()?, + &plugin_activations, + ) + .await; + let turn_plugin_activations = + mention_selection.plugin_activations_for_turn(&plugin_activations); let mut emit_error = None; let mut coding_event_mirror = coding_events::CodingEventMirror::default(); let mut proposed_plan_parser = proposed_plan_parser::ProposedPlanParser::default(); @@ -493,13 +503,14 @@ impl RuntimeBackend { runtime_initialized = true; } - let mut session_config = session_config_from_request( + let mut session_config = session_config_from_request_with_plugin_activations( &request, host_request.as_ref(), &session_scope, &selection, &request_tool_policy, config_metadata.clone(), + &turn_plugin_activations, ); mention_selection.apply_to_session_config(&mut session_config); let model_context_window = lime_agent::model_request_policy_from_turn_context( @@ -710,6 +721,13 @@ impl RuntimeBackend { }) .map(|guard| guard.clone()) } + + async fn current_plugin_activations(&self) -> Result, RuntimeCoreError> { + let Some(app_data_source) = self.current_app_data_source()? else { + return Ok(Vec::new()); + }; + app_data_source.list_plugin_catalog_activations().await + } } fn backend_error(error: impl std::fmt::Display) -> RuntimeCoreError { diff --git a/lime-rs/crates/app-server/src/runtime_backend/execution_backend.rs b/lime-rs/crates/app-server/src/runtime_backend/execution_backend.rs index a0a137d9a..da006a0b5 100644 --- a/lime-rs/crates/app-server/src/runtime_backend/execution_backend.rs +++ b/lime-rs/crates/app-server/src/runtime_backend/execution_backend.rs @@ -1,7 +1,7 @@ use super::{ action_response, current_agent_runtime_config_metadata, initialize_runtime_database, - plugin_worker_generation, request_context::effective_runtime_options_for_turn, tool_inventory, - workspace_patch_host_execution, RuntimeBackend, + mcp_bridges, plugin_worker_generation, request_context::effective_runtime_options_for_turn, + tool_inventory, workspace_patch_host_execution, RuntimeBackend, }; use crate::runtime::ToolInventoryReadRequest; use crate::{ @@ -346,6 +346,43 @@ impl ExecutionBackend for RuntimeBackend { .await } + async fn read_mcp_runtime_resource( + &self, + params: app_server_protocol::McpResourceReadParams, + ) -> Result { + let session_id = params.session_id.as_deref().ok_or_else(|| { + RuntimeCoreError::InvalidRequest( + "mcpResource/read runtime owner requires sessionId".to_string(), + ) + })?; + let thread_id = params.thread_id.as_deref().ok_or_else(|| { + RuntimeCoreError::InvalidRequest( + "mcpResource/read runtime owner requires threadId".to_string(), + ) + })?; + let db = initialize_runtime_database(self.db.as_ref())?; + self.ensure_agent_initialized(&db).await?; + mcp_bridges::ensure_thread_mcp_runtime_if_available( + &self.agent_state, + &self.app_data_source, + session_id, + thread_id, + ) + .await?; + let content = self + .agent_state + .read_mcp_resource(session_id, thread_id, ¶ms.server, ¶ms.uri) + .await + .map_err(RuntimeCoreError::Backend)?; + Ok(app_server_protocol::McpResourceReadResponse { + uri: content.uri, + mime_type: content.mime_type, + text: content.text, + blob: content.blob, + meta: content.meta, + }) + } + async fn prepare_runtime_worker_artifact_events( &self, request: &ExecutionRequest, diff --git a/lime-rs/crates/app-server/src/runtime_backend/initialization_tests.rs b/lime-rs/crates/app-server/src/runtime_backend/initialization_tests.rs index e32a3ea57..01a645659 100644 --- a/lime-rs/crates/app-server/src/runtime_backend/initialization_tests.rs +++ b/lime-rs/crates/app-server/src/runtime_backend/initialization_tests.rs @@ -170,6 +170,30 @@ async fn main_turn_initializes_agent_before_live_execution_hook() { assert!(backend.agent_state.is_initialized().await); } +#[tokio::test] +async fn mcp_resource_read_rehydrates_session_runtime_without_starting_turn() { + let backend = RuntimeBackend::with_db(test_db()); + ExecutionBackend::set_app_data_source(&backend, Arc::new(crate::NoopAppDataSource)) + .expect("set app data source"); + + assert!(!backend.agent_state.is_initialized().await); + + let error = ExecutionBackend::read_mcp_runtime_resource( + &backend, + app_server_protocol::McpResourceReadParams { + server: "missing-server".to_string(), + uri: "ui://missing-server/app.html".to_string(), + session_id: Some("session-history".to_string()), + thread_id: Some("thread-history".to_string()), + }, + ) + .await + .expect_err("empty fixture runtime has no MCP server"); + + assert!(backend.agent_state.is_initialized().await); + assert!(!error.to_string().contains("MCP runtime is not initialized")); +} + #[tokio::test] async fn unexecutable_route_returns_typed_rejection_before_provider_call() { let backend = RuntimeBackend::with_db(test_db()); diff --git a/lime-rs/crates/app-server/src/runtime_backend/mention_selection.rs b/lime-rs/crates/app-server/src/runtime_backend/mention_selection.rs index 6bdf2af75..135e53871 100644 --- a/lime-rs/crates/app-server/src/runtime_backend/mention_selection.rs +++ b/lime-rs/crates/app-server/src/runtime_backend/mention_selection.rs @@ -7,13 +7,16 @@ use std::sync::Arc; const APP_MENTION_PREFIX: &str = "app://"; const PLUGIN_MENTION_PREFIX: &str = "plugin://"; -const RUNTIME_CAPABILITY_SCHEMA_VERSION: &str = "plugin-runtime-capabilities/v0.1"; +const PLUGIN_ACTIVATION_SCHEMA_VERSION: &str = "plugin-activation/v2"; +const PLUGIN_RUNTIME_CAPABILITY_SCHEMA_VERSION: &str = "plugin-runtime-capabilities/v2"; +const LEGACY_APP_RUNTIME_CAPABILITY_SCHEMA_VERSION: &str = "plugin-runtime-capabilities/v0.1"; const MENTION_SELECTION_TURN_METADATA_KEY: &str = "mention_selection"; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(super) struct MentionSelection { apps: Vec, plugins: Vec, + plugin_filter_requested: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -29,11 +32,17 @@ struct ResolvedPluginMention { } #[derive(Debug, Clone, PartialEq, Eq)] -struct InstalledPluginRecord { +struct PluginActivationRecord { id: String, runtime_capabilities: Value, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct InstalledAppRecord { + plugin_id: String, + runtime_capabilities: Value, +} + #[derive(Debug, Default, PartialEq, Eq)] struct MentionRequests { app_ids: Vec, @@ -43,35 +52,64 @@ struct MentionRequests { pub(super) async fn resolve_mentions( request: &ExecutionRequest, app_data_source: Option>, + plugin_activations: &[Value], ) -> MentionSelection { let requests = mention_requests(request); if requests.app_ids.is_empty() && requests.plugin_ids.is_empty() { return MentionSelection::default(); } - let Some(app_data_source) = app_data_source else { + let installed_app_states = if requests.app_ids.is_empty() { + Vec::new() + } else if let Some(app_data_source) = app_data_source { + match app_data_source.list_plugin_installed().await { + Ok(installed) => installed.states, + Err(error) => { + tracing::warn!( + error = %error, + "App installed registry 不可用,app:// Mention 按 fail-closed 处理" + ); + Vec::new() + } + } + } else { tracing::warn!( app_count = requests.app_ids.len(), - plugin_count = requests.plugin_ids.len(), - "忽略无法由 installed registry 校验的结构化 Mention" + "忽略无法由 app installed registry 校验的 app:// Mention" ); - return MentionSelection::default(); - }; - let installed = match app_data_source.list_plugin_installed().await { - Ok(installed) => installed, - Err(error) => { - tracing::warn!( - error = %error, - "Plugin installed registry 不可用,结构化 Mention 按 fail-closed 处理" - ); - return MentionSelection::default(); - } + Vec::new() }; - resolve_from_installed_states(requests, &installed.states) + if !requests.plugin_ids.is_empty() && plugin_activations.is_empty() { + tracing::warn!( + plugin_count = requests.plugin_ids.len(), + "Plugin v2 activation snapshot 为空,plugin:// Mention 按 fail-closed 处理" + ); + } + + resolve_from_sources(requests, plugin_activations, &installed_app_states) } impl MentionSelection { + pub(super) fn plugin_activations_for_turn(&self, activations: &[Value]) -> Vec { + if !self.plugin_filter_requested { + return activations.to_vec(); + } + let selected_ids = self + .plugins + .iter() + .map(|plugin| plugin.id.as_str()) + .collect::>(); + activations + .iter() + .filter(|activation| { + string_at(activation, "/pluginId") + .is_some_and(|id| selected_ids.contains(id.as_str())) + }) + .cloned() + .collect() + } + pub(super) fn apply_to_session_config(&self, session_config: &mut AgentSessionConfig) { if self.apps.is_empty() && self.plugins.is_empty() { return; @@ -94,22 +132,6 @@ impl MentionSelection { })).collect::>(), }), ); - - let plugin_metadata = self - .plugins - .iter() - .map(|plugin| { - json!({ - "plugin_runtime_capabilities": plugin.runtime_capabilities, - }) - }) - .collect::>(); - let plugin_metadata = plugin_metadata.iter().collect::>(); - session_config.system_prompt = - super::plugin_runtime_context::append_plugin_runtime_context_to_system_prompt( - session_config.system_prompt.take(), - &plugin_metadata, - ); } } @@ -139,20 +161,28 @@ fn mention_id<'a>(path: &'a str, prefix: &str) -> Option<&'a str> { path.strip_prefix(prefix).filter(|id| !id.is_empty()) } -fn resolve_from_installed_states(requests: MentionRequests, states: &[Value]) -> MentionSelection { - let records = states +fn resolve_from_sources( + requests: MentionRequests, + plugin_activations: &[Value], + installed_app_states: &[Value], +) -> MentionSelection { + let plugin_records = plugin_activations .iter() - .filter_map(installed_plugin_record) + .filter_map(plugin_activation_record) + .collect::>(); + let app_records = installed_app_states + .iter() + .filter_map(installed_app_record) .collect::>(); let plugins = requests .plugin_ids .iter() - .filter_map(|id| resolve_plugin_mention(id, &records)) + .filter_map(|id| resolve_plugin_mention(id, &plugin_records)) .collect::>(); let apps = requests .app_ids .iter() - .filter_map(|id| resolve_app_mention(id, &records)) + .filter_map(|id| resolve_app_mention(id, &app_records)) .collect::>(); let unresolved_plugin_count = requests.plugin_ids.len().saturating_sub(plugins.len()); @@ -165,18 +195,41 @@ fn resolve_from_installed_states(requests: MentionRequests, states: &[Value]) -> ); } - MentionSelection { apps, plugins } + MentionSelection { + apps, + plugins, + plugin_filter_requested: !requests.plugin_ids.is_empty(), + } } -fn installed_plugin_record(state: &Value) -> Option { +fn plugin_activation_record(activation: &Value) -> Option { + if string_at(activation, "/schemaVersion").as_deref() != Some(PLUGIN_ACTIVATION_SCHEMA_VERSION) + { + return None; + } + let id = string_at(activation, "/pluginId")?; + let runtime_capabilities = activation.get("runtimeCapabilities")?.clone(); + if string_at(&runtime_capabilities, "/schemaVersion").as_deref() + != Some(PLUGIN_RUNTIME_CAPABILITY_SCHEMA_VERSION) + || string_at(&runtime_capabilities, "/pluginId").as_deref() != Some(id.as_str()) + { + return None; + } + Some(PluginActivationRecord { + id, + runtime_capabilities, + }) +} + +fn installed_app_record(state: &Value) -> Option { if state.get("disabled").and_then(Value::as_bool) != Some(false) { return None; } - let id = string_at(state, "/appId")?; + let plugin_id = string_at(state, "/appId")?; let mut runtime_capabilities = state.pointer("/manifest/runtimeCapabilities")?.clone(); if string_at(&runtime_capabilities, "/schemaVersion").as_deref() - != Some(RUNTIME_CAPABILITY_SCHEMA_VERSION) - || string_at(&runtime_capabilities, "/pluginId").as_deref() != Some(id.as_str()) + != Some(LEGACY_APP_RUNTIME_CAPABILITY_SCHEMA_VERSION) + || string_at(&runtime_capabilities, "/pluginId").as_deref() != Some(plugin_id.as_str()) { return None; } @@ -186,15 +239,15 @@ fn installed_plugin_record(state: &Value) -> Option { .entry("packageSourceUri".to_string()) .or_insert(Value::String(package_source_uri)); } - Some(InstalledPluginRecord { - id, + Some(InstalledAppRecord { + plugin_id, runtime_capabilities, }) } fn resolve_plugin_mention( id: &str, - records: &[InstalledPluginRecord], + records: &[PluginActivationRecord], ) -> Option { let mut matches = records.iter().filter(|record| record.id == id); let record = matches.next()?; @@ -207,7 +260,7 @@ fn resolve_plugin_mention( }) } -fn resolve_app_mention(id: &str, records: &[InstalledPluginRecord]) -> Option { +fn resolve_app_mention(id: &str, records: &[InstalledAppRecord]) -> Option { let mut matches = records.iter().filter(|record| { record .runtime_capabilities @@ -227,7 +280,7 @@ fn resolve_app_mention(id: &str, records: &[InstalledPluginRecord]) -> Option").count(), 1); - assert!(prompt.contains("")); - assert!(prompt.contains("plugin_id: creator")); + assert_eq!(prompt, "base"); assert!(!prompt.contains("plugin://creator")); assert!(!prompt.contains("app://docs")); } @@ -370,7 +453,7 @@ mod tests { }, "manifest": { "runtimeCapabilities": { - "schemaVersion": RUNTIME_CAPABILITY_SCHEMA_VERSION, + "schemaVersion": LEGACY_APP_RUNTIME_CAPABILITY_SCHEMA_VERSION, "pluginId": capability_plugin_id, "skills": [], "tools": connectors.iter().map(|id| json!({ @@ -383,4 +466,23 @@ mod tests { } }) } + + fn plugin_activation(plugin_id: &str, capability_plugin_id: &str) -> Value { + json!({ + "schemaVersion": PLUGIN_ACTIVATION_SCHEMA_VERSION, + "pluginId": plugin_id, + "version": "1.0.0", + "contentDigest": format!("sha256:{plugin_id}"), + "marketplaceId": "test-marketplace", + "packageSourceUri": format!("/plugins/{plugin_id}"), + "runtimeCapabilities": { + "schemaVersion": PLUGIN_RUNTIME_CAPABILITY_SCHEMA_VERSION, + "pluginId": capability_plugin_id, + "version": "1.0.0", + "packageSourceUri": format!("/plugins/{plugin_id}"), + "skills": [], + "mcpBindings": [], + } + }) + } } diff --git a/lime-rs/crates/app-server/src/runtime_backend/request_context.rs b/lime-rs/crates/app-server/src/runtime_backend/request_context.rs index 9e41af668..6b128f699 100644 --- a/lime-rs/crates/app-server/src/runtime_backend/request_context.rs +++ b/lime-rs/crates/app-server/src/runtime_backend/request_context.rs @@ -28,7 +28,9 @@ mod session_config; mod turn_context; mod workspace_scope; +#[cfg(test)] pub(super) use session_config::session_config_from_request; +pub(super) use session_config::session_config_from_request_with_plugin_activations; pub(super) use turn_context::turn_context_from_request; pub(super) use workspace_scope::request_workspace_scope; diff --git a/lime-rs/crates/app-server/src/runtime_backend/request_context/session_config.rs b/lime-rs/crates/app-server/src/runtime_backend/request_context/session_config.rs index 15f3ee19e..38cc0b046 100644 --- a/lime-rs/crates/app-server/src/runtime_backend/request_context/session_config.rs +++ b/lime-rs/crates/app-server/src/runtime_backend/request_context/session_config.rs @@ -19,6 +19,7 @@ const DEFAULT_SESSION_SYSTEM_PROMPT: &str = "你是 Lime 桌面端中的 AI 助 const HARNESS_MAX_PROVIDER_STEPS_POINTER: &str = "/harness/provider_budget/max_provider_steps"; const HARNESS_PROVIDER_TOKEN_BUDGET_POINTER: &str = "/harness/provider_budget/token_budget"; +#[cfg(test)] pub(in crate::runtime_backend) fn session_config_from_request( request: &ExecutionRequest, host_request: Option<&RuntimeRequest>, @@ -26,9 +27,34 @@ pub(in crate::runtime_backend) fn session_config_from_request( selection: &RuntimeModelSelection, request_tool_policy: &RequestToolPolicy, config_metadata: Option, +) -> AgentSessionConfig { + session_config_from_request_with_plugin_activations( + request, + host_request, + scope, + selection, + request_tool_policy, + config_metadata, + &[], + ) +} + +pub(in crate::runtime_backend) fn session_config_from_request_with_plugin_activations( + request: &ExecutionRequest, + host_request: Option<&RuntimeRequest>, + scope: &RuntimeSessionScope, + selection: &RuntimeModelSelection, + request_tool_policy: &RequestToolPolicy, + config_metadata: Option, + plugin_activations: &[Value], ) -> AgentSessionConfig { let workspace_scope = request_workspace_scope(request, host_request); - let metadata_values = super::super::skill_runtime_enable::request_metadata_values(request); + let mut metadata_values = super::super::skill_runtime_enable::request_metadata_values(request); + let plugin_activation_metadata = plugin_activations + .iter() + .map(|activation| serde_json::json!({ "plugin_activation": activation })) + .collect::>(); + metadata_values.extend(plugin_activation_metadata.iter()); let turn_tool_surface = super::turn_tool_surface_for_request(request); let runtime_metadata = request.runtime_metadata(); let (system_prompt, skill_snapshot) = if turn_tool_surface.uses_light_session_prompt() { @@ -93,6 +119,19 @@ pub(in crate::runtime_backend) fn session_config_from_request( serde_json::to_value(skill_snapshot).expect("skill snapshot must serialize"), ); } + if !plugin_activations.is_empty() { + let turn_context = turn_context.get_or_insert_with(Default::default); + turn_context.metadata.insert( + "plugin_activations".to_string(), + Value::Array(plugin_activations.to_vec()), + ); + if plugin_activations.len() == 1 { + turn_context.metadata.insert( + "plugin_activation".to_string(), + plugin_activations[0].clone(), + ); + } + } if turn_tool_surface.is_harness_direct_answer() { let turn_context = turn_context.get_or_insert_with(Default::default); let runtime = turn_context diff --git a/lime-rs/crates/app-server/src/runtime_backend/tests/session_skill_context.rs b/lime-rs/crates/app-server/src/runtime_backend/tests/session_skill_context.rs index 7ccb1881f..5eac50963 100644 --- a/lime-rs/crates/app-server/src/runtime_backend/tests/session_skill_context.rs +++ b/lime-rs/crates/app-server/src/runtime_backend/tests/session_skill_context.rs @@ -62,6 +62,95 @@ Use concise language. assert!(snapshot.skills.iter().any(|skill| skill.name == "writer")); } +#[test] +fn session_config_loads_enabled_plugin_v2_skills_and_freezes_identity() { + let workspace = TempDir::new().expect("workspace"); + let plugin_root = workspace.path().join("plugins/browser"); + let skill_dir = plugin_root.join("skills/control-browser"); + std::fs::create_dir_all(&skill_dir).expect("plugin skill dir"); + std::fs::write( + skill_dir.join("SKILL.md"), + r#"--- +name: Control Browser +description: Inspect pages through the managed browser. +--- + +# Control Browser + +Use the managed browser session. +"#, + ) + .expect("plugin skill file"); + let mut request = request_for_test("检查当前页面", None, None); + let options = request.runtime_options.as_mut().expect("runtime options"); + options.runtime_request_mut().provider_preference = Some("openai".to_string()); + options.runtime_request_mut().model_preference = Some("gpt-4.1".to_string()); + let host_request = runtime_request_from_request(&request); + let scope = session_scope_from_request(&request).expect("session scope"); + let selection = selection_from_explicit_preferences(&request).expect("selection"); + let policy = request_tool_policy_from_request(host_request.as_ref()); + let activations = vec![json!({ + "schemaVersion": "plugin-activation/v2", + "pluginId": "browser", + "version": "1.0.0", + "contentDigest": "sha256:fixture", + "marketplaceId": "openai-bundled", + "packageSourceUri": plugin_root.to_string_lossy(), + "runtimeCapabilities": { + "schemaVersion": "plugin-runtime-capabilities/v2", + "pluginId": "browser", + "version": "1.0.0", + "packageSourceUri": plugin_root.to_string_lossy(), + "skills": [{ + "id": "control-browser", + "title": "Control Browser", + "path": "./skills/", + "activation": "available", + "required": false, + "promptInjectionPolicy": { + "mode": "available", + "source": "plugin-v2-installed" + } + }], + "mcpBindings": [] + } + })]; + + let config = session_config_from_request_with_plugin_activations( + &request, + host_request.as_ref(), + &scope, + &selection, + &policy, + None, + &activations, + ); + + let prompt = config.system_prompt.expect("system prompt"); + assert!(prompt.contains("")); + assert!(prompt.contains("plugin_id: browser")); + let turn_context = config.turn_context.expect("turn context"); + assert_eq!( + turn_context.metadata["plugin_activation"]["pluginId"], + "browser" + ); + assert_eq!( + turn_context.metadata["plugin_activations"], + Value::Array(activations) + ); + let snapshot = turn_context + .metadata + .get(lime_skills::SKILL_SNAPSHOT_TURN_METADATA_KEY) + .and_then(|value| { + serde_json::from_value::(value.clone()).ok() + }) + .expect("skill snapshot"); + assert!(snapshot + .skills + .iter() + .any(|skill| skill.name.eq_ignore_ascii_case("control-browser"))); +} + #[test] fn session_config_appends_plugin_activation_metadata_to_system_prompt() { let mut request = request_for_test( diff --git a/lime-rs/crates/app-server/src/skills_watcher.rs b/lime-rs/crates/app-server/src/skills_watcher.rs index 9f9d9794b..e6cd31bb9 100644 --- a/lime-rs/crates/app-server/src/skills_watcher.rs +++ b/lime-rs/crates/app-server/src/skills_watcher.rs @@ -5,18 +5,110 @@ use std::collections::HashSet; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use tokio::task::AbortHandle; const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10); pub(crate) struct SkillsWatcher { _watcher: Arc>, _watched_roots: Arc>>, + notifier: CatalogChangeNotifier, reconcile_task: tokio::task::JoinHandle<()>, } impl Drop for SkillsWatcher { fn drop(&mut self) { self.reconcile_task.abort(); + self.notifier.cancel_pending(); + } +} + +#[derive(Default)] +struct NotificationState { + last_notification: Option, + trailing_task: Option, +} + +#[derive(Clone)] +struct CatalogChangeNotifier { + bridge: AppServerEventBridge, + handle: tokio::runtime::Handle, + throttle_interval: Duration, + state: Arc>, +} + +impl CatalogChangeNotifier { + fn new( + bridge: AppServerEventBridge, + handle: tokio::runtime::Handle, + throttle_interval: Duration, + ) -> Self { + Self { + bridge, + handle, + throttle_interval, + state: Arc::new(Mutex::new(NotificationState::default())), + } + } + + fn notify(&self) { + let now = Instant::now(); + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + + if state.trailing_task.is_some() { + return; + } + + let Some(last_notification) = state.last_notification else { + state.last_notification = Some(now); + drop(state); + self.broadcast(); + return; + }; + let elapsed = now.duration_since(last_notification); + if elapsed >= self.throttle_interval { + state.last_notification = Some(now); + drop(state); + self.broadcast(); + return; + } + + let delay = self.throttle_interval - elapsed; + let notifier = self.clone(); + let task = self.handle.spawn(async move { + tokio::time::sleep(delay).await; + notifier.flush_trailing(); + }); + state.trailing_task = Some(task.abort_handle()); + } + + fn flush_trailing(&self) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.trailing_task.take().is_none() { + return; + } + state.last_notification = Some(Instant::now()); + drop(state); + self.broadcast(); + } + + fn cancel_pending(&self) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if let Some(task) = state.trailing_task.take() { + task.abort(); + } + } + + fn broadcast(&self) { + lime_skills::invalidate_agent_skill_snapshot_cache(); + let bridge = self.bridge.clone(); + self.handle.spawn(async move { + bridge + .broadcast_message(JsonRpcMessage::Notification( + ServerNotification::SkillsChanged(SkillsChangedNotification {}).into(), + )) + .await; + }); } } @@ -48,10 +140,8 @@ impl SkillsWatcher { tracing::warn!("skills watcher has no default Skill roots"); return None; } - let last_notification = Arc::new(Mutex::new(None::)); - let callback_last_notification = last_notification.clone(); - let callback_bridge = bridge.clone(); - let callback_handle = handle.clone(); + let notifier = CatalogChangeNotifier::new(bridge, handle, throttle_interval); + let callback_notifier = notifier.clone(); let watcher = match notify::recommended_watcher(move |result: notify::Result| { let event = match result { Ok(event) if is_catalog_change(&event.kind) => event, @@ -64,12 +154,7 @@ impl SkillsWatcher { if event.paths.is_empty() { return; } - notify_catalog_changed( - &callback_bridge, - &callback_handle, - &callback_last_notification, - throttle_interval, - ); + callback_notifier.notify(); }) { Ok(watcher) => watcher, Err(error) => { @@ -83,21 +168,15 @@ impl SkillsWatcher { let reconcile_watcher = watcher.clone(); let reconcile_watched_roots = watched_roots.clone(); - let reconcile_last_notification = last_notification.clone(); - let reconcile_handle = handle.clone(); - let reconcile_task = handle.spawn(async move { + let reconcile_notifier = notifier.clone(); + let reconcile_task = notifier.handle.spawn(async move { let mut interval = tokio::time::interval(throttle_interval); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); interval.tick().await; loop { interval.tick().await; if reconcile_watches(&reconcile_watcher, &reconcile_watched_roots, &roots) { - notify_catalog_changed( - &bridge, - &reconcile_handle, - &reconcile_last_notification, - throttle_interval, - ); + reconcile_notifier.notify(); } } }); @@ -105,6 +184,7 @@ impl SkillsWatcher { Some(Arc::new(Self { _watcher: watcher, _watched_roots: watched_roots, + notifier, reconcile_task, })) } @@ -155,33 +235,6 @@ fn reconcile_watches( changed } -fn notify_catalog_changed( - bridge: &AppServerEventBridge, - handle: &tokio::runtime::Handle, - last_notification: &Mutex>, - throttle_interval: Duration, -) { - let now = Instant::now(); - let mut last = last_notification - .lock() - .unwrap_or_else(|error| error.into_inner()); - if last.is_some_and(|last| now.duration_since(last) < throttle_interval) { - return; - } - *last = Some(now); - drop(last); - - lime_skills::invalidate_agent_skill_snapshot_cache(); - let bridge = bridge.clone(); - handle.spawn(async move { - bridge - .broadcast_message(JsonRpcMessage::Notification( - ServerNotification::SkillsChanged(SkillsChangedNotification {}).into(), - )) - .await; - }); -} - fn is_catalog_change(kind: &EventKind) -> bool { matches!( kind, @@ -251,6 +304,30 @@ mod tests { ); } + #[tokio::test] + async fn throttled_change_is_coalesced_into_trailing_notification() { + let server = AppServer::new(); + let mut outbound = server.subscribe_outbound_messages(); + let notifier = CatalogChangeNotifier::new( + server.event_bridge(), + tokio::runtime::Handle::current(), + Duration::from_millis(500), + ); + + notifier.notify(); + receive_skills_changed(&mut outbound, Duration::from_secs(1)).await; + + notifier.notify(); + assert!( + tokio::time::timeout(Duration::from_millis(100), outbound.recv()) + .await + .is_err(), + "throttled change must not broadcast immediately" + ); + receive_skills_changed(&mut outbound, Duration::from_secs(1)).await; + notifier.cancel_pending(); + } + #[tokio::test] async fn skill_root_created_after_start_broadcasts_typed_invalidation() { let temp = tempfile::tempdir().expect("tempdir"); @@ -293,4 +370,24 @@ mod tests { ) ); } + + async fn receive_skills_changed( + outbound: &mut tokio::sync::broadcast::Receiver, + timeout: Duration, + ) -> JsonRpcMessage { + tokio::time::timeout(timeout, async { + loop { + let message = outbound.recv().await.expect("watcher notification"); + if matches!( + &message, + JsonRpcMessage::Notification(notification) + if notification.method == "skills/changed" + ) { + break message; + } + } + }) + .await + .expect("wait for skills/changed") + } } diff --git a/lime-rs/crates/mcp/src/manager.rs b/lime-rs/crates/mcp/src/manager.rs index 83c716c03..9273e35b3 100644 --- a/lime-rs/crates/mcp/src/manager.rs +++ b/lime-rs/crates/mcp/src/manager.rs @@ -124,6 +124,7 @@ pub struct McpRuntimeOwner { #[derive(Clone, Debug, PartialEq, Eq)] pub struct McpRuntimeServerSpec { pub name: String, + pub plugin_id: Option, pub config: McpServerConfig, } @@ -135,6 +136,7 @@ pub struct McpRuntimeServerSpec { #[derive(Clone)] pub struct McpBridgeSnapshot { pub server_name: String, + pub plugin_id: Option, pub environment_id: String, pub description: String, pub tools: Vec, @@ -443,6 +445,7 @@ impl McpClientManager { snapshots.push(McpBridgeSnapshot { server_name: server_name.clone(), + plugin_id: None, environment_id: wrapper.config.environment_id.clone(), description, tools: server_tools, diff --git a/lime-rs/crates/mcp/src/manager/resources.rs b/lime-rs/crates/mcp/src/manager/resources.rs index 29d9909d0..e1ef8174e 100644 --- a/lime-rs/crates/mcp/src/manager/resources.rs +++ b/lime-rs/crates/mcp/src/manager/resources.rs @@ -289,23 +289,25 @@ impl McpClientManager { uri: content_uri, mime_type, text, - .. + meta, } => McpResourceContent { uri: content_uri, mime_type, text: Some(text), blob: None, + meta: meta.map(|value| serde_json::Value::Object(value.0)), }, rmcp::model::ResourceContents::BlobResourceContents { uri: content_uri, mime_type, blob, - .. + meta, } => McpResourceContent { uri: content_uri, mime_type, text: None, blob: Some(blob), + meta: meta.map(|value| serde_json::Value::Object(value.0)), }, } } else { @@ -315,6 +317,7 @@ impl McpClientManager { mime_type: None, text: None, blob: None, + meta: None, } } } diff --git a/lime-rs/crates/mcp/src/manager/tests/resources.rs b/lime-rs/crates/mcp/src/manager/tests/resources.rs index 3a9348aa6..7b0fcc571 100644 --- a/lime-rs/crates/mcp/src/manager/tests/resources.rs +++ b/lime-rs/crates/mcp/src/manager/tests/resources.rs @@ -309,6 +309,43 @@ fn test_convert_read_resource_result_text() { assert_eq!(mcp_content.mime_type, Some("text".to_string())); assert_eq!(mcp_content.text, Some("Hello, World!".to_string())); assert!(mcp_content.blob.is_none()); + assert!(mcp_content.meta.is_none()); +} + +#[test] +fn test_convert_read_resource_result_preserves_meta() { + let mut meta = rmcp::model::Meta::new(); + meta.0.insert( + "ui".to_string(), + serde_json::json!({ + "csp": { + "connectDomains": ["https://api.example.com"], + "resourceDomains": ["https://cdn.example.com"] + } + }), + ); + let result = rmcp::model::ReadResourceResult { + contents: vec![rmcp::model::ResourceContents::TextResourceContents { + uri: "ui://plugin/report.html".to_string(), + mime_type: Some("text/html".to_string()), + text: "
report
".to_string(), + meta: Some(meta), + }], + }; + + let content = McpClientManager::convert_read_resource_result("ui://plugin/report.html", result); + + assert_eq!( + content.meta, + Some(serde_json::json!({ + "ui": { + "csp": { + "connectDomains": ["https://api.example.com"], + "resourceDomains": ["https://cdn.example.com"] + } + } + })) + ); } #[test] @@ -334,6 +371,7 @@ fn test_convert_read_resource_result_blob() { ); assert!(mcp_content.text.is_none()); assert_eq!(mcp_content.blob, Some("base64encodeddata".to_string())); + assert!(mcp_content.meta.is_none()); } #[test] @@ -349,4 +387,5 @@ fn test_convert_read_resource_result_empty() { assert!(mcp_content.mime_type.is_none()); assert!(mcp_content.text.is_none()); assert!(mcp_content.blob.is_none()); + assert!(mcp_content.meta.is_none()); } diff --git a/lime-rs/crates/mcp/src/types.rs b/lime-rs/crates/mcp/src/types.rs index 8f8077e68..f57aee484 100644 --- a/lime-rs/crates/mcp/src/types.rs +++ b/lime-rs/crates/mcp/src/types.rs @@ -685,6 +685,7 @@ pub struct McpResourceContent { pub mime_type: Option, pub text: Option, pub blob: Option, + pub meta: Option, } // ============================================================================ diff --git a/lime-rs/crates/thread-store/src/history_merge.rs b/lime-rs/crates/thread-store/src/history_merge.rs index 55abb6a43..7b1e51b14 100644 --- a/lime-rs/crates/thread-store/src/history_merge.rs +++ b/lime-rs/crates/thread-store/src/history_merge.rs @@ -5,13 +5,18 @@ use agent_protocol::{ use std::collections::HashMap; pub(super) fn merge_item_snapshot(previous: ThreadItem, mut next: ThreadItem) -> ThreadItem { + let source_event_type = next + .metadata + .get("source_event_type") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); next.created_at_ms = previous.created_at_ms; next.ordinal = previous.ordinal; next.completed_at_ms = next.completed_at_ms.or(previous.completed_at_ms); if previous.status.is_terminal() && !next.status.is_terminal() { next.status = previous.status; } - next.payload = merge_payload(previous.payload, next.payload); + next.payload = merge_payload(previous.payload, next.payload, source_event_type.as_deref()); next } @@ -36,7 +41,11 @@ pub(super) fn merge_turn_snapshot(previous: Turn, mut next: Turn) -> Turn { next } -fn merge_payload(previous: ThreadItemPayload, next: ThreadItemPayload) -> ThreadItemPayload { +fn merge_payload( + previous: ThreadItemPayload, + next: ThreadItemPayload, + source_event_type: Option<&str>, +) -> ThreadItemPayload { use ThreadItemPayload::*; match (previous, next) { @@ -102,11 +111,25 @@ fn merge_payload(previous: ThreadItemPayload, next: ThreadItemPayload) -> Thread summary: next_summary, content: next_content, }, - ) => { - extend_distinct(&mut summary, next_summary); - extend_distinct(&mut content, next_content); - Reasoning { summary, content } - } + ) => match source_event_type { + Some("reasoning.final" | "item.completed" | "item.updated") => Reasoning { + summary: if next_summary.is_empty() { + summary + } else { + next_summary + }, + content: if next_content.is_empty() { + content + } else { + next_content + }, + }, + _ => { + summary.extend(next_summary); + content.extend(next_content); + Reasoning { summary, content } + } + }, ( Tool { call_id: old_call, @@ -135,6 +158,8 @@ fn merge_payload(previous: ThreadItemPayload, next: ThreadItemPayload) -> Thread call_id: old_call, server_name: old_server, tool_name: old_tool, + mcp_app_resource_uri: old_mcp_app_resource_uri, + plugin_id: old_plugin_id, arguments: old_args, output: old_output, }, @@ -142,6 +167,8 @@ fn merge_payload(previous: ThreadItemPayload, next: ThreadItemPayload) -> Thread call_id, server_name, tool_name, + mcp_app_resource_uri, + plugin_id, arguments, output, }, @@ -149,6 +176,8 @@ fn merge_payload(previous: ThreadItemPayload, next: ThreadItemPayload) -> Thread call_id: prefer_string(old_call, call_id, ""), server_name: prefer_string(old_server, server_name, "unknown"), tool_name: prefer_string(old_tool, tool_name, "tool"), + mcp_app_resource_uri: mcp_app_resource_uri.or(old_mcp_app_resource_uri), + plugin_id: plugin_id.or(old_plugin_id), arguments: if arguments.is_empty() { old_args } else { @@ -340,14 +369,6 @@ fn merge_stream_text(previous: String, next: String) -> String { } } -fn extend_distinct(values: &mut Vec, next: Vec) { - for value in next { - if !values.contains(&value) { - values.push(value); - } - } -} - /// Batch-local coalescer. Event lowering may emit several snapshots for one /// identity; the durable builder owns the resulting merge, while this helper /// only preserves first-change order within one batch. diff --git a/lime-rs/crates/thread-store/src/history_tests.rs b/lime-rs/crates/thread-store/src/history_tests.rs index d658c4b0a..aeef0545c 100644 --- a/lime-rs/crates/thread-store/src/history_tests.rs +++ b/lime-rs/crates/thread-store/src/history_tests.rs @@ -30,6 +30,23 @@ fn item(sequence: u64, ordinal: u64, id: &str, text: &str) -> ThreadItem { } } +fn reasoning_item(sequence: u64, content: &str, source_event_type: &str) -> ThreadItem { + let mut item = ThreadItem::new( + SessionId::new("session-1"), + ThreadId::new("thread-1"), + TurnId::new("turn-1"), + sequence, + 1, + ThreadItemPayload::Reasoning { + summary: Vec::new(), + content: vec![content.to_string()], + }, + ); + item.item_id = ItemId::new("reasoning-1"); + item.metadata = serde_json::json!({"source_event_type": source_event_type}); + item +} + fn turn(id: &str, status: TurnStatus) -> Turn { Turn { session_id: SessionId::new("session-1"), @@ -102,6 +119,50 @@ fn coalesces_repeated_item_snapshots_and_preserves_first_order() { )); } +#[test] +fn reasoning_history_preserves_repeated_deltas_and_replaces_final_snapshot() { + let mut builder = ThreadHistoryBuilder::new(); + builder + .append_turns_at(1, vec![turn("turn-1", TurnStatus::InProgress)]) + .expect("turn append"); + builder + .append_items_at(2, vec![reasoning_item(2, ".", "reasoning.delta")]) + .expect("first reasoning delta"); + builder + .append_items_at(3, vec![reasoning_item(3, ".", "reasoning.delta")]) + .expect("repeated reasoning delta"); + + assert!(matches!( + &builder.raw_items()[0].payload, + ThreadItemPayload::Reasoning { content, .. } + if content == &[".".to_string(), ".".to_string()] + )); + + builder + .append_items_at(4, vec![reasoning_item(4, "done", "reasoning.final")]) + .expect("reasoning final snapshot"); + assert!(matches!( + &builder.raw_items()[0].payload, + ThreadItemPayload::Reasoning { content, .. } if content == &["done".to_string()] + )); + + let mut completed = reasoning_item(5, "", "item.completed"); + completed.status = ItemStatus::Completed; + completed.completed_at_ms = Some(5); + completed.payload = ThreadItemPayload::Reasoning { + summary: Vec::new(), + content: Vec::new(), + }; + builder + .append_items_at(5, vec![completed]) + .expect("empty reasoning completion"); + assert!(matches!( + &builder.raw_items()[0].payload, + ThreadItemPayload::Reasoning { content, .. } if content == &["done".to_string()] + )); + assert_eq!(builder.raw_items()[0].status, ItemStatus::Completed); +} + #[test] fn item_first_append_attaches_items_when_turn_arrives() { let mut builder = ThreadHistoryBuilder::new(); diff --git a/lime-rs/crates/tool-runtime/src/mcp_connection.rs b/lime-rs/crates/tool-runtime/src/mcp_connection.rs index e95b4a039..06887eef4 100644 --- a/lime-rs/crates/tool-runtime/src/mcp_connection.rs +++ b/lime-rs/crates/tool-runtime/src/mcp_connection.rs @@ -14,6 +14,8 @@ pub use step_snapshot::{McpStepRouteIdentity, McpStepSnapshot}; pub struct McpConnectionProvenance { environment_id: String, auth_scopes: Option>, + server_name: Option, + plugin_id: Option, } impl Default for McpConnectionProvenance { @@ -27,9 +29,25 @@ impl McpConnectionProvenance { Self { environment_id: environment_id.into(), auth_scopes, + server_name: None, + plugin_id: None, } } + pub fn with_server_name(mut self, server_name: Option) -> Self { + self.server_name = server_name + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + self + } + + pub fn with_plugin_id(mut self, plugin_id: Option) -> Self { + self.plugin_id = plugin_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + self + } + pub fn environment_id(&self) -> &str { &self.environment_id } @@ -37,6 +55,14 @@ impl McpConnectionProvenance { pub fn auth_scopes(&self) -> Option<&[String]> { self.auth_scopes.as_deref() } + + pub fn server_name(&self) -> Option<&str> { + self.server_name.as_deref() + } + + pub fn plugin_id(&self) -> Option<&str> { + self.plugin_id.as_deref() + } } pub type McpConnectionError = rmcp::ServiceError; diff --git a/lime-rs/crates/tool-runtime/src/mcp_connection/step_snapshot.rs b/lime-rs/crates/tool-runtime/src/mcp_connection/step_snapshot.rs index e57af771f..d83b19264 100644 --- a/lime-rs/crates/tool-runtime/src/mcp_connection/step_snapshot.rs +++ b/lime-rs/crates/tool-runtime/src/mcp_connection/step_snapshot.rs @@ -14,6 +14,7 @@ use tokio_util::sync::CancellationToken; struct McpStepRoute { server_name: String, tool_name: String, + mcp_app_resource_uri: Option, allowed_callers: Option>, provenance: McpConnectionProvenance, supports_parallel_tool_calls: bool, @@ -25,6 +26,8 @@ pub struct McpStepRouteIdentity { pub server_name: String, pub tool_name: String, pub runtime_tool_name: String, + pub mcp_app_resource_uri: Option, + pub plugin_id: Option, } #[derive(Clone)] @@ -76,6 +79,8 @@ impl McpStepSnapshot { server_name: route.server_name.clone(), tool_name: route.tool_name.clone(), runtime_tool_name: runtime_tool_name.to_string(), + mcp_app_resource_uri: route.mcp_app_resource_uri.clone(), + plugin_id: route.provenance.plugin_id().map(ToOwned::to_owned), }) } @@ -207,6 +212,7 @@ async fn capture_connection( loop { for tool in page.tools { let tool_name = tool.name.to_string(); + let mcp_app_resource_uri = mcp_app_resource_uri(&tool); let prefixed_name = format!("{connection_name}__{tool_name}"); let visible = config.is_tool_exposed_by_default(&tool.name) || selected_deferred_tools.contains(&prefixed_name); @@ -230,8 +236,12 @@ async fn capture_connection( meta: tool.meta, }, McpStepRoute { - server_name: connection_name.clone(), + server_name: provenance + .server_name() + .unwrap_or(&connection_name) + .to_string(), tool_name, + mcp_app_resource_uri, allowed_callers, provenance: provenance.clone(), supports_parallel_tool_calls, @@ -259,6 +269,19 @@ async fn capture_connection( } } +fn mcp_app_resource_uri(tool: &Tool) -> Option { + let meta = tool.meta.as_ref()?; + meta.get("ui") + .and_then(Value::as_object) + .and_then(|ui| ui.get("resourceUri")) + .and_then(Value::as_str) + .or_else(|| meta.get("ui/resourceUri").and_then(Value::as_str)) + .or_else(|| meta.get("openai/outputTemplate").and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + fn tool_allowed_callers(tool: &Tool, config: &RuntimeExtensionConfig) -> Option> { extract_tool_surface_metadata( tool.name.as_ref(), @@ -284,7 +307,7 @@ mod tests { use super::*; use crate::mcp_connection::{McpConnection, McpConnectionError, McpConnectionRegistry}; use async_trait::async_trait; - use rmcp::model::{CallToolResult, Content, JsonObject, ListToolsResult}; + use rmcp::model::{CallToolResult, Content, JsonObject, ListToolsResult, Meta}; use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::sync::Mutex; @@ -452,11 +475,69 @@ mod tests { server_name: "docs".to_string(), tool_name: "search".to_string(), runtime_tool_name: "docs__search".to_string(), + mcp_app_resource_uri: None, + plugin_id: None, }) ); assert_eq!(parallel.route_identity("missing"), None); } + #[tokio::test] + async fn snapshot_freezes_plugin_identity_and_mcp_app_resource_uri() { + let registry = McpConnectionRegistry::new(); + let config = RuntimeExtensionConfig::new( + "mcp__plugin__docs__demo", + "Plugin docs tools", + vec!["search".to_string()], + false, + vec!["search".to_string()], + None, + ); + let mut app_tool = tool("search", None); + app_tool.meta = Some(Meta( + serde_json::json!({ + "ui": { "resourceUri": "ui://plugin/docs.html" }, + "ui/resourceUri": "ui://plugin/flat.html", + "openai/outputTemplate": "ui://plugin/fallback.html" + }) + .as_object() + .expect("tool meta object") + .clone(), + )); + let connection: McpConnectionHandle = Arc::new(Mutex::new(Box::new(TestConnection { + tools: vec![app_tool], + output: "docs".to_string(), + discovery_mode: DiscoveryMode::Ready, + call_count: Arc::new(AtomicUsize::new(0)), + observed_scope: None, + }))); + registry + .register( + "mcp__plugin__docs__demo".to_string(), + config, + McpConnectionProvenance::default() + .with_server_name(Some("plugin__docs__demo".to_string())) + .with_plugin_id(Some("docs-plugin".to_string())), + false, + connection, + ) + .await; + + let captured = snapshot(®istry, HashSet::new(), Duration::from_secs(1)).await; + registry.remove("mcp__plugin__docs__demo").await; + + assert_eq!( + captured.route_identity("mcp__plugin__docs__demo__search"), + Some(McpStepRouteIdentity { + server_name: "plugin__docs__demo".to_string(), + tool_name: "search".to_string(), + runtime_tool_name: "mcp__plugin__docs__demo__search".to_string(), + mcp_app_resource_uri: Some("ui://plugin/docs.html".to_string()), + plugin_id: Some("docs-plugin".to_string()), + }) + ); + } + #[tokio::test] async fn dispatch_binds_snapshot_generation_to_call_scope() { let registry = McpConnectionRegistry::new(); @@ -651,6 +732,7 @@ mod tests { let denied_route = McpStepRoute { server_name: "mixed".to_string(), tool_name: "execute".to_string(), + mcp_app_resource_uri: None, allowed_callers: Some(vec!["code_execution".to_string()]), provenance: McpConnectionProvenance::default(), supports_parallel_tool_calls: false, diff --git a/lime-rs/crates/tool-runtime/src/mcp_resource.rs b/lime-rs/crates/tool-runtime/src/mcp_resource.rs index 8e0724cce..cfe043e65 100644 --- a/lime-rs/crates/tool-runtime/src/mcp_resource.rs +++ b/lime-rs/crates/tool-runtime/src/mcp_resource.rs @@ -111,6 +111,8 @@ impl RuntimeToolExecutor for RuntimeMcpResourceExecutor { .read_mcp_resource(McpResourceReadParams { server: server.to_string(), uri: uri.to_string(), + session_id: None, + thread_id: None, }) .await .map_err(|error| { @@ -311,6 +313,7 @@ mod tests { mime_type: Some("text/markdown".to_string()), text: Some("hello".to_string()), blob: None, + meta: None, }) } } diff --git a/lime-rs/crates/tool-runtime/src/native_dispatch.rs b/lime-rs/crates/tool-runtime/src/native_dispatch.rs index 808b52766..ca1f9ce36 100644 --- a/lime-rs/crates/tool-runtime/src/native_dispatch.rs +++ b/lime-rs/crates/tool-runtime/src/native_dispatch.rs @@ -454,6 +454,7 @@ mod tests { mime_type: None, text: Some("resource".to_string()), blob: None, + meta: None, }) } } diff --git a/package.json b/package.json index e3b1cfc68..c7568c8f5 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "workspaces": [ "packages/*" ], - "version": "1.120.1", + "version": "1.121.0", "description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.", "keywords": [ "ai", @@ -240,6 +240,7 @@ "smoke:mcp-workspace-plugin-runtime-electron-fixture": "node scripts/electron/mcp-workspace-plugin-runtime-fixture-smoke.mjs", "smoke:mcp-context7-live-electron-fixture": "node scripts/electron/mcp-context7-live-fixture-smoke.mjs", "smoke:mcp-elicitation-gate-b": "node scripts/electron/mcp-elicitation-gate-b.mjs", + "smoke:plugin-v2-current-electron-fixture": "node scripts/electron/plugin-v2-current-electron-fixture.mjs", "smoke:code-artifact-workbench-electron-fixture": "node scripts/electron/code-artifact-workbench-fixture-smoke.mjs", "smoke:plugin-ui-runtime-electron-fixture": "node scripts/plugin/ui-runtime-electron-fixture-smoke.mjs", "smoke:plugin-runtime-electron-fixture": "node scripts/plugin/runtime-electron-fixture-smoke.mjs", diff --git a/packages/app-server-client/src/generated/protocol-types.ts b/packages/app-server-client/src/generated/protocol-types.ts index aeefd7faa..9a4ade2ab 100644 --- a/packages/app-server-client/src/generated/protocol-types.ts +++ b/packages/app-server-client/src/generated/protocol-types.ts @@ -243,6 +243,12 @@ export const METHOD_MODEL_PROVIDER_UI_STATE_READ = "modelProviderUiState/read"; export const METHOD_MODEL_PROVIDER_UI_STATE_WRITE = "modelProviderUiState/write"; export const METHOD_MODEL_SYNC_STATE_READ = "modelSyncState/read"; +export const METHOD_PLUGIN_ENABLED_SET = "plugin/enabled/set"; +export const METHOD_PLUGIN_INSTALL = "plugin/install"; +export const METHOD_PLUGIN_INSTALLED = "plugin/installed"; +export const METHOD_PLUGIN_LIST = "plugin/list"; +export const METHOD_PLUGIN_READ = "plugin/read"; +export const METHOD_PLUGIN_UNINSTALL = "plugin/uninstall"; export const METHOD_PLUGIN_HOST_LIFECYCLE_LIST = "pluginHostLifecycle/list"; export const METHOD_PLUGIN_INSTALLED_DISABLED_SET = "pluginInstalled/disabled/set"; @@ -1176,6 +1182,30 @@ export const GENERATED_APP_SERVER_METHODS = [ kind: "request", method: "modelSyncState/read", }, + { + kind: "request", + method: "plugin/enabled/set", + }, + { + kind: "request", + method: "plugin/install", + }, + { + kind: "request", + method: "plugin/installed", + }, + { + kind: "request", + method: "plugin/list", + }, + { + kind: "request", + method: "plugin/read", + }, + { + kind: "request", + method: "plugin/uninstall", + }, { kind: "request", method: "pluginHostLifecycle/list", @@ -4604,6 +4634,36 @@ export type ClientRequest = method: "model/list"; params: ModelListParams; } + | { + id: number | string; + method: "plugin/list"; + params: PluginCatalogListParams; + } + | { + id: number | string; + method: "plugin/read"; + params: PluginCatalogReadParams; + } + | { + id: number | string; + method: "plugin/install"; + params: PluginCatalogInstallParams; + } + | { + id: number | string; + method: "plugin/uninstall"; + params: PluginCatalogUninstallParams; + } + | { + id: number | string; + method: "plugin/installed"; + params: PluginCatalogInstalledParams; + } + | { + id: number | string; + method: "plugin/enabled/set"; + params: PluginCatalogEnabledSetParams; + } | { id: number | string; method: "turn/start"; @@ -5958,11 +6018,14 @@ export interface McpResourceListResponse { export interface McpResourceReadParams { server: string; + sessionId?: null | string; + threadId?: null | string; uri: string; } export interface McpResourceReadResponse { blob?: null | string; + meta?: unknown; mime_type?: null | string; text?: null | string; uri: string; @@ -6563,6 +6626,12 @@ export type Method = | "artifact/write" | "media/read" | "model/list" + | "plugin/enabled/set" + | "plugin/install" + | "plugin/installed" + | "plugin/list" + | "plugin/read" + | "plugin/uninstall" | "thread/approveGuardianDeniedAction" | "thread/archive" | "thread/backgroundTerminals/clean" @@ -7074,6 +7143,105 @@ export interface PluginArticleWorkspaceObject { title: string; } +export interface PluginCatalogCapability { + description: string; + id: string; + name: string; + requiresAuth: boolean; +} + +export interface PluginCatalogDetail { + apps: PluginCatalogCapability[]; + hooks: PluginCatalogHook[]; + mcpServers: PluginCatalogCapability[]; + skills: PluginCatalogCapability[]; + summary: PluginCatalogSummary; + uiResources: PluginCatalogUiResource[]; +} + +export interface PluginCatalogEnabledSetParams { + enabled: boolean; + pluginId: string; +} + +export interface PluginCatalogEnabledSetResponse { + plugin: PluginCatalogSummary; +} + +export interface PluginCatalogHook { + event: string; + id: string; +} + +export interface PluginCatalogInstallParams { + expectedDigest?: null | string; + marketplaceId?: null | string; + source?: null | string; + sourcePath: string; +} + +export interface PluginCatalogInstallResponse { + plugin: PluginCatalogSummary; +} + +export type PluginCatalogInstalledParams = Record; + +export interface PluginCatalogListParams { + marketplacePaths?: string[]; + query?: null | string; + source?: null | string; +} + +export interface PluginCatalogListResponse { + generatedAt: string; + plugins: PluginCatalogSummary[]; +} + +export interface PluginCatalogReadParams { + pluginId: string; +} + +export interface PluginCatalogReadResponse { + plugin: PluginCatalogDetail; +} + +export interface PluginCatalogSummary { + appsCount: number; + authPolicy: string; + availability: string; + contentDigest: string; + description: string; + disabledReason?: null | string; + enabled: boolean; + hooksCount: number; + id: string; + installPolicy: string; + installed: boolean; + localVersion?: null | string; + marketplaceId: string; + mcpServersCount: number; + name: string; + skillsCount: number; + source: string; + sourceUri: string; + version: string; +} + +export interface PluginCatalogUiResource { + id: string; + kind: string; + resourceUri: string; +} + +export interface PluginCatalogUninstallParams { + pluginId: string; +} + +export interface PluginCatalogUninstallResponse { + pluginId: string; + uninstalled: boolean; +} + export interface PluginCloudReleaseDescriptor { appId: string; channel?: null | string; diff --git a/packages/app-server-client/src/request-client-methods.ts b/packages/app-server-client/src/request-client-methods.ts index 300c18579..023c7347f 100644 --- a/packages/app-server-client/src/request-client-methods.ts +++ b/packages/app-server-client/src/request-client-methods.ts @@ -523,6 +523,42 @@ export const APP_SERVER_REQUEST_CLIENT_METHODS: readonly AppServerRequestClientM kind: "request", params: "required", }, + { + name: "setPluginCatalogEnabled", + method: protocol.METHOD_PLUGIN_ENABLED_SET, + kind: "request", + params: "required", + }, + { + name: "listPluginCatalog", + method: protocol.METHOD_PLUGIN_LIST, + kind: "request", + params: "optional-empty", + }, + { + name: "readPluginCatalog", + method: protocol.METHOD_PLUGIN_READ, + kind: "request", + params: "required", + }, + { + name: "installPluginCatalog", + method: protocol.METHOD_PLUGIN_INSTALL, + kind: "request", + params: "required", + }, + { + name: "uninstallPluginCatalog", + method: protocol.METHOD_PLUGIN_UNINSTALL, + kind: "request", + params: "required", + }, + { + name: "listInstalledPluginCatalog", + method: protocol.METHOD_PLUGIN_INSTALLED, + kind: "request", + params: "optional-empty", + }, { name: "inspectPluginLocalPackage", method: protocol.METHOD_PLUGIN_LOCAL_PACKAGE_INSPECT, diff --git a/packages/app-server-client/src/request-client.ts b/packages/app-server-client/src/request-client.ts index e190958db..0a6f8be3d 100644 --- a/packages/app-server-client/src/request-client.ts +++ b/packages/app-server-client/src/request-client.ts @@ -175,6 +175,24 @@ export interface AppServerClient { executeBrowserSessionAction( params: protocol.BrowserSessionActionExecuteParams, ): protocol.JsonRpcRequest; + listPluginCatalog( + params?: protocol.PluginCatalogListParams, + ): protocol.JsonRpcRequest; + setPluginCatalogEnabled( + params: protocol.PluginCatalogEnabledSetParams, + ): protocol.JsonRpcRequest; + readPluginCatalog( + params: protocol.PluginCatalogReadParams, + ): protocol.JsonRpcRequest; + installPluginCatalog( + params: protocol.PluginCatalogInstallParams, + ): protocol.JsonRpcRequest; + uninstallPluginCatalog( + params: protocol.PluginCatalogUninstallParams, + ): protocol.JsonRpcRequest; + listInstalledPluginCatalog( + params?: protocol.PluginCatalogInstalledParams, + ): protocol.JsonRpcRequest; listSkills(): protocol.JsonRpcRequest; readSkill(params: protocol.SkillReadParams): protocol.JsonRpcRequest; listManagementSkills( diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index e4afb9254..7cfc3b99c 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.120.1", + "version": "1.121.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/scripts/agent-runtime/tool-execution-soak-evidence.mjs b/scripts/agent-runtime/tool-execution-soak-evidence.mjs index a062e65e9..ef19f4a7e 100644 --- a/scripts/agent-runtime/tool-execution-soak-evidence.mjs +++ b/scripts/agent-runtime/tool-execution-soak-evidence.mjs @@ -238,6 +238,24 @@ function countByKind(records) { return counts; } +export function resolveSoakRoundThreadIdentity(evidence) { + const sessionId = String(evidence?.runtime?.sessionId || "").trim(); + const threadId = String(evidence?.runtime?.threadId || "").trim(); + if (!sessionId || !threadId) { + throw new Error( + "SOAK evidence 缺少 canonical sessionId/threadId,拒绝使用 sessionId 代替 threadId", + ); + } + return { sessionId, threadId }; +} + +export function containsCanonicalThread(threads, { sessionId, threadId }) { + return threads.some( + (thread) => + thread?.id === threadId && thread?.sessionId === sessionId, + ); +} + export async function collectSoakRoundObservation({ evidence, outputPath, @@ -257,16 +275,19 @@ export async function collectSoakRoundObservation({ }); } }; - const sessionId = String(evidence?.runtime?.sessionId || "").trim(); - const sessionRead = await invokeObserved("thread/read", { - sessionId, - historyLimit: 500, + const { sessionId, threadId } = resolveSoakRoundThreadIdentity(evidence); + const threadRead = await invokeObserved("thread/read", { + threadId, + includeTurns: true, }); - const threadId = String(sessionRead?.session?.threadId || "").trim(); + const canonicalThread = threadRead?.thread; + if (!canonicalThread || typeof canonicalThread !== "object") { + throw new Error("thread/read 未返回 canonical thread"); + } const [itemsResponse, threadsResponse, turnsResponse] = await Promise.all([ invokeObserved("thread/items/list", { threadId, limit: 500 }), invokeObserved("thread/list", { - includeArchived: true, + archived: false, limit: 500, turnsView: "summary", }), @@ -292,7 +313,7 @@ export async function collectSoakRoundObservation({ status: evidence?.status || "unknown", sessionId, threadId, - sessionStatus: sessionRead?.session?.status ?? null, + sessionStatus: canonicalThread.status ?? null, threadCount: threads.length, sessionThreadCount: threads.filter( (thread) => thread?.sessionId === sessionId, @@ -310,9 +331,10 @@ export async function collectSoakRoundObservation({ assertions: { roundEvidencePassed: evidence?.status === "pass", canonicalSessionIdentityPresent: Boolean(sessionId && threadId), - canonicalThreadListed: threads.some( - (thread) => thread?.threadId === threadId, - ), + canonicalThreadListed: containsCanonicalThread(threads, { + sessionId, + threadId, + }), exactlyOneTurnRecorded: turns.length === 1, terminalTurnRecordedOnce: terminalTurns.length === 1, turnIdentityUnique: new Set(turnIds).size === turnIds.length, diff --git a/scripts/agent-runtime/tool-execution-soak-evidence.test.mjs b/scripts/agent-runtime/tool-execution-soak-evidence.test.mjs index a442a5b0d..f32f7d68c 100644 --- a/scripts/agent-runtime/tool-execution-soak-evidence.test.mjs +++ b/scripts/agent-runtime/tool-execution-soak-evidence.test.mjs @@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest"; import { buildSoakSummary, childArgsForRound, + containsCanonicalThread, parsePosixProcessRows, parseWindowsProcessRows, + resolveSoakRoundThreadIdentity, resolveSoakConfig, roundEvidencePath, summarizeProcessTree, @@ -65,6 +67,40 @@ describe("tool execution SOAK evidence", () => { ); }); + it("reads SOAK threads by the canonical thread id", () => { + expect( + resolveSoakRoundThreadIdentity({ + runtime: { + sessionId: "session-1", + threadId: "thread-1", + }, + }), + ).toEqual({ sessionId: "session-1", threadId: "thread-1" }); + expect(() => + resolveSoakRoundThreadIdentity({ + runtime: { sessionId: "session-1" }, + }), + ).toThrow(/canonical sessionId\/threadId/); + }); + + it("matches v2 thread/list entries by id and sessionId", () => { + expect( + containsCanonicalThread( + [ + { id: "thread-1", sessionId: "session-1" }, + { id: "thread-2", sessionId: "session-2" }, + ], + { sessionId: "session-1", threadId: "thread-1" }, + ), + ).toBe(true); + expect( + containsCanonicalThread( + [{ id: "thread-1", sessionId: "session-other" }], + { sessionId: "session-1", threadId: "thread-1" }, + ), + ).toBe(false); + }); + it("parses POSIX and Windows process inventories", () => { expect( parsePosixProcessRows( diff --git a/scripts/electron/build-renderer-smoke.mjs b/scripts/electron/build-renderer-smoke.mjs index 036c5c260..a5300cdc6 100644 --- a/scripts/electron/build-renderer-smoke.mjs +++ b/scripts/electron/build-renderer-smoke.mjs @@ -3,14 +3,20 @@ process.env.LIME_ELECTRON_RENDERER = "1"; process.env.LIME_VITE_EMPTY_OUT_DIR = "0"; const { spawn } = await import("node:child_process"); -const { rendererBuildEnv, startRendererBuildHeartbeat } = +const { + acquireRendererBuildLock, + rendererBuildEnv, + startRendererBuildHeartbeat, +} = await import("./renderer-build-env.mjs"); +const releaseBuildLock = acquireRendererBuildLock(); const stopHeartbeat = startRendererBuildHeartbeat(); try { await run("npx", ["vite", "build", "--base", "./"]); } finally { stopHeartbeat(); + releaseBuildLock(); } function run(command, args) { diff --git a/scripts/electron/build-renderer.mjs b/scripts/electron/build-renderer.mjs index 230b98191..fd08d12df 100644 --- a/scripts/electron/build-renderer.mjs +++ b/scripts/electron/build-renderer.mjs @@ -1,14 +1,20 @@ process.env.LIME_ELECTRON_RENDERER = "1"; const { spawn } = await import("node:child_process"); -const { rendererBuildEnv, startRendererBuildHeartbeat } = +const { + acquireRendererBuildLock, + rendererBuildEnv, + startRendererBuildHeartbeat, +} = await import("./renderer-build-env.mjs"); +const releaseBuildLock = acquireRendererBuildLock(); const stopHeartbeat = startRendererBuildHeartbeat(); try { await run("npx", ["vite", "build", "--base", "./"]); } finally { stopHeartbeat(); + releaseBuildLock(); } function run(command, args) { diff --git a/scripts/electron/copy-desktop-assets.mjs b/scripts/electron/copy-desktop-assets.mjs index 5ae225a51..09ea06339 100644 --- a/scripts/electron/copy-desktop-assets.mjs +++ b/scripts/electron/copy-desktop-assets.mjs @@ -1,7 +1,11 @@ -import { copyFile, mkdir } from "node:fs/promises"; +import { copyFile, cp, mkdir } from "node:fs/promises"; import path from "node:path"; const outputDir = path.resolve("dist-electron/desktop-assets"); +const bundledPluginSource = path.resolve("assets/plugins/openai-bundled"); +const bundledPluginOutput = path.resolve( + "dist-electron/plugins/openai-bundled", +); const assets = [ ["lime-rs/icons/icon.png", "icon.png"], @@ -19,4 +23,10 @@ for (const [source, filename] of assets) { await copyFile(path.resolve(source), path.join(outputDir, filename)); } +await cp(bundledPluginSource, bundledPluginOutput, { + recursive: true, + force: true, +}); + console.log(`[electron-assets] copied ${assets.length} assets to ${outputDir}`); +console.log(`[electron-assets] copied bundled plugins to ${bundledPluginOutput}`); diff --git a/scripts/electron/current-entrypoints.test.mjs b/scripts/electron/current-entrypoints.test.mjs index 5aea037f6..5ebe7f2c9 100644 --- a/scripts/electron/current-entrypoints.test.mjs +++ b/scripts/electron/current-entrypoints.test.mjs @@ -445,10 +445,12 @@ describe("Electron current package entrypoints", () => { ); expect(buildRenderer).toContain("LIME_ELECTRON_RENDERER"); + expect(buildRenderer).toContain("acquireRendererBuildLock"); expect(buildRenderer).toContain("rendererBuildEnv"); expect(buildRenderer).toContain("startRendererBuildHeartbeat"); expect(smokeBuildRenderer).toContain("LIME_ELECTRON_RENDERER"); expect(smokeBuildRenderer).toContain('LIME_VITE_EMPTY_OUT_DIR = "0"'); + expect(smokeBuildRenderer).toContain("acquireRendererBuildLock"); expect(smokeBuildRenderer).toContain("rendererBuildEnv"); expect(smokeBuildRenderer).toContain("startRendererBuildHeartbeat"); expect(rendererBuildEnv).toContain("--max-old-space-size=8192"); diff --git a/scripts/electron/forge-config.test.mjs b/scripts/electron/forge-config.test.mjs index 610907f93..24b379d0d 100644 --- a/scripts/electron/forge-config.test.mjs +++ b/scripts/electron/forge-config.test.mjs @@ -246,4 +246,10 @@ describe("Electron Forge config", () => { ignorePackagerInput(`${process.cwd()}/scripts/electron/smoke.mjs`), ).toBe(true); }); + + it("packages the bundled Plugin marketplace beside the app-server resources", () => { + expect(forgeConfig.packagerConfig.extraResource).toContain( + "dist-electron/plugins", + ); + }); }); diff --git a/scripts/electron/mcp-elicitation-gate-b.mjs b/scripts/electron/mcp-elicitation-gate-b.mjs index d0c9539a0..964399bd2 100644 --- a/scripts/electron/mcp-elicitation-gate-b.mjs +++ b/scripts/electron/mcp-elicitation-gate-b.mjs @@ -4,8 +4,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; import { resolveElectronAppServerRuntimeEnv } from "../lib/electron-app-server-assets.mjs"; import { resolveDevAppServerBinary } from "../lib/electron-dev-sidecar.mjs"; +import { ensureElectronFixtureBuild } from "../lib/electron-fixture-build.mjs"; import { startOpenAiCompatibleFixtureServer } from "../lib/openai-compatible-fixture-server.mjs"; import { APP_SERVER_HANDLE_JSON_LINES_COMMAND, @@ -24,6 +27,15 @@ import { sanitizeText, sleep, } from "./mcp-config-fixture-smoke.mjs"; +import { + findPluginV2McpAppItems, + installPluginV2EmbeddedBrowserLifecycleCapture, + PLUGIN_V2_MCP_APP_MARKER, + PLUGIN_V2_MCP_APP_RESOURCE_URI, + pluginV2McpAppHtml, + waitForPluginV2McpAppHistoryUnavailable, + waitForPluginV2McpAppSurface, +} from "./plugin-v2-mcp-app-gate-b.mjs"; const DEFAULTS = { evidenceDir: path.join( @@ -41,7 +53,21 @@ const DEFAULTS = { const LOG_PREFIX = "[smoke:mcp-elicitation-gate-b]"; const FINAL_TEXT = "MCP_ELICITATION_GATE_B_DONE"; +const DISABLED_BOUNDARY_FINAL_TEXT = "PLUGIN_DISABLED_BOUNDARY_DONE"; const TOOL_SUFFIX = "release_check"; +const MCP_TOOL_CALL_ID = "call-mcp-elicitation-release-check"; +const PLUGIN_V2_ID = "mcp-elicitation-plugin"; +const PLUGIN_V2_SERVER_ID = "demo"; +const PLUGIN_V2_DISPLAY_NAME = "Plugin v2 MCP Gate B"; +const PLUGIN_V2_BUNDLED_ID = "browser"; +const PLUGIN_V2_BUNDLED_MARKETPLACE_ID = "openai-bundled"; +const PLUGIN_V2_BUNDLED_VERSION = "1.0.0"; +const LEGACY_PLUGIN_WORKER_COMMANDS = [ + "plugin_runtime_start_task", + "plugin_runtime_get_task", + "plugin_runtime_submit_host_response", + "plugin_runtime_cancel_task", +]; const DYNAMIC_TOOL_NAME = "desktop__appInfo"; const DYNAMIC_TOOL_CALL_ID = "call-desktop-app-info"; const NAVIGATION_RESTORE_STORAGE_KEY = "lime.appNavigation.restore.v1"; @@ -59,6 +85,24 @@ const REQUIRED_METHODS = [ "turn/start", "thread/read", ]; +const PLUGIN_V2_REQUIRED_METHODS = [ + "workspace/default/ensure", + "modelProvider/create", + "modelProvider/update", + "modelProviderKey/create", + "model/list", + "plugin/list", + "plugin/install", + "plugin/read", + "plugin/installed", + "plugin/enabled/set", + "plugin/uninstall", + "mcpResource/read", + "thread/start", + "thread/settings/update", + "turn/start", + "thread/read", +]; function printHelp() { console.log(` @@ -84,8 +128,8 @@ MCP Elicitation Gate B `); } -function parseArgs(argv) { - const options = { ...DEFAULTS }; +function parseArgs(argv, defaults = DEFAULTS) { + const options = { ...defaults }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; const next = argv[index + 1]; @@ -140,6 +184,32 @@ function toolName(serverName) { return `mcp__${serverName}__${TOOL_SUFFIX}`; } +function readRepositoryCommit(rootDir) { + try { + return execFileSync("git", ["rev-parse", "HEAD"], { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return null; + } +} + +async function readElectronRuntime(app) { + return app.evaluate(({ app: electronApp }) => ({ + appVersion: electronApp.getVersion(), + arch: process.arch, + electronVersion: process.versions.electron ?? null, + pid: process.pid, + platform: process.platform, + })); +} + +function pluginV2ServerName() { + return `plugin__${PLUGIN_V2_ID}__${PLUGIN_V2_SERVER_ID}`; +} + function readJsonLines(filePath) { if (!fs.existsSync(filePath)) return []; return fs @@ -166,6 +236,8 @@ function writeElicitationFixture() { import readline from "node:readline"; const ledgerPath = process.argv[2]; +const mcpAppResourceUri = ${JSON.stringify(PLUGIN_V2_MCP_APP_RESOURCE_URI)}; +const mcpAppHtml = ${JSON.stringify(pluginV2McpAppHtml())}; const pending = new Map(); let nextElicitationId = 1; let initializedProtocolVersion = null; @@ -216,7 +288,7 @@ rl.on("line", (line) => { }); result(id, { protocolVersion: initializedProtocolVersion ?? "2025-03-26", - capabilities: { tools: {} }, + capabilities: { resources: {}, tools: {} }, serverInfo: { name: "elicitation-gate-b-fixture", version: "1.0.0" }, }); return; @@ -238,11 +310,13 @@ rl.on("line", (line) => { required: ["release"], additionalProperties: false, }, + _meta: { ui: { resourceUri: mcpAppResourceUri } }, }], }); return; } if (method === "tools/call") { + record({ type: "tool_call", pid: process.pid, name: params?.name ?? null }); if (!supportsFormElicitation()) { record({ type: "capability_missing", @@ -275,6 +349,27 @@ rl.on("line", (line) => { }); return; } + if (method === "resources/read") { + record({ type: "resource_read", pid: process.pid, uri: params?.uri ?? null }); + result(id, { + contents: [{ + uri: params?.uri, + mimeType: "text/html;profile=mcp-app", + text: mcpAppHtml, + _meta: { + ui: { + csp: { + baseUriDomains: [], + connectDomains: [], + frameDomains: [], + resourceDomains: [], + }, + }, + }, + }], + }); + return; + } if (pending.has(String(id))) { const request = pending.get(String(id)); pending.delete(String(id)); @@ -296,6 +391,50 @@ rl.on("line", (line) => { return { ledgerPath, root, serverPath }; } +function preparePluginV2Package(fixture) { + const manifestDir = path.join(fixture.root, ".codex-plugin"); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync( + path.join(manifestDir, "plugin.json"), + `${JSON.stringify( + { + name: PLUGIN_V2_ID, + version: "1.0.0", + description: "Plugin v2 MCP Gate B fixture", + mcpServers: "./.mcp.json", + interface: { displayName: PLUGIN_V2_DISPLAY_NAME }, + }, + null, + 2, + )}\n`, + ); + fs.writeFileSync( + path.join(fixture.root, ".mcp.json"), + `${JSON.stringify( + { + mcpServers: { + [PLUGIN_V2_SERVER_ID]: { + command: process.execPath, + args: [path.basename(fixture.serverPath), fixture.ledgerPath], + required: true, + startup_timeout: 10, + tool_timeout: 60, + }, + }, + }, + null, + 2, + )}\n`, + ); + return { + id: PLUGIN_V2_ID, + root: fixture.root, + runtimeServerName: pluginV2ServerName(), + serverId: PLUGIN_V2_SERVER_ID, + version: "1.0.0", + }; +} + async function cleanupMcpServer(page, server) { if (!page || !server?.id) return; await appServerCallFromPage(page, "mcpServer/stop", { @@ -306,6 +445,428 @@ async function cleanupMcpServer(page, server) { }).catch(() => undefined); } +async function cleanupPluginV2(page, plugin) { + if (!page || !plugin?.id) return; + await appServerCallFromPage(page, "plugin/uninstall", { + pluginId: plugin.id, + }).catch(() => undefined); +} + +async function observeAppServerMethodFromTrace( + page, + method, + options, + observedMethods, +) { + const startedAt = Date.now(); + while (Date.now() - startedAt < Math.min(options.timeoutMs, 45_000)) { + const traceRaw = + (await page.evaluate(() => + window.localStorage.getItem("lime_invoke_trace_buffer_v1"), + )) || ""; + if ( + parseJsonRpcRequestsFromInvokeTrace(traceRaw).some( + (request) => request?.method === method, + ) + ) { + observedMethods.add(method); + return traceRaw; + } + await sleep(options.intervalMs); + } + throw new Error(`Renderer 未通过 App Server bridge 调用 ${method}`); +} + +async function armControlledPluginSourceDialog(app, sourcePath) { + await app.evaluate(({ dialog }, controlledSourcePath) => { + globalThis.__limePluginV2DirectoryDialogCalls = []; + const originalShowOpenDialog = dialog.showOpenDialog.bind(dialog); + dialog.showOpenDialog = async (...args) => { + const dialogOptions = args.at(-1) ?? null; + globalThis.__limePluginV2DirectoryDialogCalls.push(dialogOptions); + dialog.showOpenDialog = originalShowOpenDialog; + return { + canceled: false, + filePaths: [controlledSourcePath], + }; + }; + }, sourcePath); +} + +async function readControlledPluginSourceDialogCalls(app) { + return app.evaluate(() => + Array.isArray(globalThis.__limePluginV2DirectoryDialogCalls) + ? globalThis.__limePluginV2DirectoryDialogCalls + : [], + ); +} + +async function waitForPluginInstalledState( + page, + pluginId, + expectedEnabled, + options, + observedMethods, +) { + const startedAt = Date.now(); + let latest = null; + while (Date.now() - startedAt < Math.min(options.timeoutMs, 45_000)) { + latest = await appServerCallFromPage(page, "plugin/installed", {}); + observedMethods.add(latest.method); + const plugin = latest.result?.plugins?.find( + (candidate) => candidate?.id === pluginId, + ); + if (plugin && plugin.enabled === expectedEnabled) { + return { plugin, response: latest }; + } + await sleep(options.intervalMs); + } + throw new Error( + `plugin/installed 状态未收敛: plugin=${pluginId} enabled=${expectedEnabled} latest=${JSON.stringify(latest?.result)}`, + ); +} + +async function openPluginV2AppCenter( + page, + options, + observedMethods, + screenshotPath, +) { + await page.locator('[data-testid="app-sidebar-nav-plugins"]').click(); + await page.locator('[data-testid="plugin-v2-install-local"]').waitFor({ + state: "visible", + timeout: options.timeoutMs, + }); + await page.locator('[data-testid="plugin-v2-loading"]').waitFor({ + state: "hidden", + timeout: options.timeoutMs, + }); + + const catalog = await appServerCallFromPage(page, "plugin/list", {}); + observedMethods.add(catalog.method); + const bundled = catalog.result?.plugins?.find( + (candidate) => candidate?.id === PLUGIN_V2_BUNDLED_ID, + ); + assert(bundled, "plugin/list 未返回 bundled Browser Plugin"); + assert( + bundled.source === "bundled" && + bundled.marketplaceId === PLUGIN_V2_BUNDLED_MARKETPLACE_ID && + bundled.version === PLUGIN_V2_BUNDLED_VERSION, + `bundled Plugin identity/version 不一致: ${JSON.stringify(bundled)}`, + ); + const bundledCard = page.locator( + `[data-testid="plugin-v2-card-${PLUGIN_V2_BUNDLED_ID}"]`, + ); + await bundledCard.waitFor({ state: "visible", timeout: options.timeoutMs }); + assert( + (await bundledCard.textContent())?.includes(PLUGIN_V2_BUNDLED_VERSION), + "App Center bundled 卡片未显示 manifest version", + ); + await page.screenshot({ path: screenshotPath, fullPage: true }); + return { bundled, catalog }; +} + +async function installPluginV2FromAppCenter({ + app, + page, + plugin, + options, + observedMethods, + screenshotPath, +}) { + await armControlledPluginSourceDialog(app, plugin.root); + await page.locator('[data-testid="plugin-v2-install-local"]').click(); + const review = page.locator('[data-testid="plugin-v2-install-review"]'); + await review.waitFor({ state: "visible", timeout: options.timeoutMs }); + assert( + (await review.textContent())?.includes(plugin.id), + "App Center 安装 review 未显示 Plugin identity", + ); + const directoryDialogCalls = await readControlledPluginSourceDialogCalls(app); + assert( + directoryDialogCalls.length === 1 && + directoryDialogCalls[0]?.properties?.includes("openDirectory"), + `未观察到 Electron 原生目录选择: ${JSON.stringify(directoryDialogCalls)}`, + ); + await page.screenshot({ path: screenshotPath, fullPage: true }); + + await page.locator('[data-testid="plugin-v2-confirm-install"]').click(); + await review.waitFor({ state: "hidden", timeout: options.timeoutMs }); + await observeAppServerMethodFromTrace( + page, + "plugin/install", + options, + observedMethods, + ); + await page + .locator(`[data-testid="plugin-v2-actions-${plugin.id}"]`) + .waitFor({ state: "visible", timeout: options.timeoutMs }); + + const installed = await waitForPluginInstalledState( + page, + plugin.id, + true, + options, + observedMethods, + ); + const read = await appServerCallFromPage(page, "plugin/read", { + pluginId: plugin.id, + }); + observedMethods.add(read.method); + const summary = read.result?.plugin?.summary; + assert(summary?.id === plugin.id, "plugin/read identity 不一致"); + assert( + String(summary?.contentDigest || "").startsWith("sha256:"), + "plugin/read 未返回 content digest", + ); + assert( + read.result?.plugin?.mcpServers?.some( + (server) => server?.id === plugin.serverId, + ), + "plugin/read 未投影 .mcp.json server", + ); + return { directoryDialogCalls, installed, read }; +} + +async function setPluginV2EnabledFromAppCenter( + page, + pluginId, + enabled, + options, + observedMethods, +) { + await page.locator('[data-testid="app-sidebar-nav-plugins"]').click(); + await page.locator('[data-testid="plugin-v2-install-local"]').waitFor({ + state: "visible", + timeout: options.timeoutMs, + }); + await page.locator('[data-testid="plugin-v2-loading"]').waitFor({ + state: "hidden", + timeout: options.timeoutMs, + }); + await page + .locator(`[data-testid="plugin-v2-actions-${pluginId}"]`) + .waitFor({ state: "visible", timeout: options.timeoutMs }); + await page.locator(`[data-testid="plugin-v2-actions-${pluginId}"]`).click(); + await page.locator(`[data-testid="plugin-v2-toggle-${pluginId}"]`).click(); + await observeAppServerMethodFromTrace( + page, + "plugin/enabled/set", + options, + observedMethods, + ); + return waitForPluginInstalledState( + page, + pluginId, + enabled, + options, + observedMethods, + ); +} + +async function verifyPluginV2DisabledNewThreadBoundary({ + page, + fixture, + plugin, + options, + observedMethods, +}) { + const workspace = await ensureWorkspace(page, observedMethods); + const route = await createRepositoryProvider(page, fixture, observedMethods); + const start = await appServerCallFromPage(page, "thread/start", { + cwd: workspace.rootPath, + historyMode: "paginated", + model: route.model, + modelProvider: route.providerId, + runtimeWorkspaceRoots: [workspace.rootPath], + serviceName: "Plugin disabled boundary", + threadSource: "appServer", + }); + observedMethods.add(start.method); + const sessionId = String(start.result?.thread?.sessionId || "").trim(); + const threadId = String(start.result?.thread?.id || "").trim(); + assert(sessionId && threadId, "禁用边界 thread/start 未返回 identity"); + const update = await appServerCallFromPage(page, "thread/settings/update", { + threadId, + modelProvider: route.providerId, + model: route.model, + }); + observedMethods.add(update.method); + + await openRuntimeThreadInGui(page, sessionId, options); + const guiModelSelection = await selectRuntimeRouteInRenderer(page, options, { + sessionId, + route, + }); + await page.locator('[data-testid="inputbar-plus-trigger"]').click(); + await page.locator('[data-testid="inputbar-plus-menu"]').waitFor({ + state: "visible", + timeout: options.timeoutMs, + }); + await page.locator('[data-testid="inputbar-plus-plugins"]').click(); + const pluginPanel = page.locator( + '[data-testid="inputbar-plus-panel-plugins"]', + ); + await pluginPanel.waitFor({ state: "visible", timeout: options.timeoutMs }); + const disabledOption = pluginPanel + .locator('[data-testid="inputbar-plugin-option"]') + .filter({ hasText: PLUGIN_V2_DISPLAY_NAME }) + .first(); + await disabledOption.waitFor({ + state: "visible", + timeout: options.timeoutMs, + }); + const disabledOptionBlocked = await disabledOption.isDisabled(); + assert( + disabledOptionBlocked, + "禁用 Plugin 在新 Thread 的 Claw picker 中仍可调用", + ); + await page.keyboard.press("Escape"); + await page.keyboard.press("Escape"); + + const turn = await appServerCallFromPage(page, "turn/start", { + threadId, + clientUserMessageId: `plugin-disabled-${Date.now()}-${process.pid}`, + input: [ + { + type: "text", + text: "Confirm the disabled plugin boundary without using plugin tools.", + }, + ], + cwd: workspace.rootPath, + runtimeWorkspaceRoots: [workspace.rootPath], + model: route.model, + approvalPolicy: "never", + sandboxPolicy: "danger-full-access", + additionalContext: { + metadata: { + kind: "application", + value: JSON.stringify({ + harness: { source: "smoke:plugin-v2-disabled-boundary" }, + tool_scope: { + allowed_tools: [ + DYNAMIC_TOOL_NAME, + toolName(plugin.runtimeServerName), + ], + }, + }), + }, + }, + }); + observedMethods.add(turn.method); + const turnId = String(turn.result?.turn?.id || "").trim(); + assert(turnId, "禁用边界 turn/start 未返回 turn.id"); + + const startedAt = Date.now(); + let latestRead = null; + while (Date.now() - startedAt < options.timeoutMs) { + latestRead = await appServerCallFromPage(page, "thread/read", { + threadId, + includeTurns: true, + }); + observedMethods.add(latestRead.method); + if ( + fixture.requests.length >= 1 && + JSON.stringify(latestRead.result || {}).includes( + DISABLED_BOUNDARY_FINAL_TEXT, + ) + ) { + break; + } + await sleep(options.intervalMs); + } + assert( + fixture.requests.length >= 1 && + JSON.stringify(latestRead?.result || {}).includes( + DISABLED_BOUNDARY_FINAL_TEXT, + ), + "禁用边界 turn 未进入 terminal read model", + ); + const requests = providerRequestSummary(fixture.requests); + const expectedToolName = toolName(plugin.runtimeServerName); + assert( + requests.every((request) => !request.toolNames.includes(expectedToolName)), + "禁用 Plugin 的 MCP tool 泄露到新 Thread provider request", + ); + assert( + findPluginV2McpAppItems(latestRead.result).length === 0, + "禁用 Plugin 在新 Thread 生成了 MCP tool item", + ); + + return { + pluginPickerBlocked: disabledOptionBlocked, + guiModelSelection, + providerRequests: requests, + providerToolAbsent: true, + sessionId, + start, + threadId, + threadPluginItemsAbsent: true, + turn, + turnId, + update, + }; +} + +async function waitForPluginUninstalled( + page, + pluginId, + options, + observedMethods, +) { + const startedAt = Date.now(); + let latest = null; + while (Date.now() - startedAt < Math.min(options.timeoutMs, 45_000)) { + latest = await appServerCallFromPage(page, "plugin/installed", {}); + observedMethods.add(latest.method); + if ( + !latest.result?.plugins?.some((candidate) => candidate?.id === pluginId) + ) { + return latest; + } + await sleep(options.intervalMs); + } + throw new Error( + `plugin/installed 卸载状态未收敛: plugin=${pluginId} latest=${JSON.stringify(latest?.result)}`, + ); +} + +async function uninstallPluginV2FromAppCenter({ + page, + pluginId, + options, + observedMethods, + screenshotPath, +}) { + await page.locator('[data-testid="app-sidebar-nav-plugins"]').click(); + await page.locator('[data-testid="plugin-v2-install-local"]').waitFor({ + state: "visible", + timeout: options.timeoutMs, + }); + await page.locator('[data-testid="plugin-v2-loading"]').waitFor({ + state: "hidden", + timeout: options.timeoutMs, + }); + const actions = page.locator(`[data-testid="plugin-v2-actions-${pluginId}"]`); + await actions.waitFor({ state: "visible", timeout: options.timeoutMs }); + await actions.click(); + await page.locator(`[data-testid="plugin-v2-uninstall-${pluginId}"]`).click(); + const confirmation = page.locator( + '[data-testid="plugin-v2-uninstall-confirm"]', + ); + await confirmation.waitFor({ state: "visible", timeout: options.timeoutMs }); + await page.screenshot({ path: screenshotPath, fullPage: true }); + await confirmation.locator("button").last().click(); + await confirmation.waitFor({ state: "hidden", timeout: options.timeoutMs }); + await observeAppServerMethodFromTrace( + page, + "plugin/uninstall", + options, + observedMethods, + ); + await waitForPluginUninstalled(page, pluginId, options, observedMethods); +} + async function waitForTool(page, expectedToolName, options, observedMethods) { const startedAt = Date.now(); let latest = null; @@ -344,8 +905,9 @@ async function ensureWorkspace(page, observedMethods) { } async function createRepositoryProvider(page, fixture, observedMethods) { + const providerName = `MCP elicitation Gate B ${Date.now()}`; const created = await appServerCallFromPage(page, "modelProvider/create", { - name: `MCP elicitation Gate B ${Date.now()}`, + name: providerName, providerType: fixture.provider.providerName, apiHost: fixture.provider.providerConfig.baseUrl, }); @@ -400,6 +962,7 @@ async function createRepositoryProvider(page, fixture, observedMethods) { catalogModel: model, model: fixture.provider.modelPreference, providerId, + providerName, }; } @@ -431,9 +994,424 @@ async function openRuntimeThreadInGui(page, sessionId, options) { return { activeSessionId: sessionId }; } +async function readRendererHistoryRequests(page, method, sessionId) { + const traceRaw = + (await page.evaluate(() => + window.localStorage.getItem("lime_invoke_trace_buffer_v1"), + )) || ""; + return parseJsonRpcRequestsFromInvokeTrace(traceRaw).filter( + (request) => + request?.method === method && request?.params?.threadId === sessionId, + ); +} + +async function waitForRendererHistoryHydrationAfterClick( + page, + sessionId, + previousCounts, + options, +) { + const methods = ["thread/read", "thread/items/list", "thread/turns/list"]; + const startedAt = Date.now(); + let requestsByMethod = {}; + while (Date.now() - startedAt < options.timeoutMs) { + requestsByMethod = Object.fromEntries( + await Promise.all( + methods.map(async (method) => [ + method, + await readRendererHistoryRequests(page, method, sessionId), + ]), + ), + ); + if ( + methods.every( + (method) => requestsByMethod[method].length > previousCounts[method], + ) + ) { + return { + requests: Object.fromEntries( + methods.map((method) => [ + method, + { + count: requestsByMethod[method].length, + latest: requestsByMethod[method].at(-1), + previousCount: previousCounts[method], + }, + ]), + ), + }; + } + await sleep(options.intervalMs); + } + throw new Error( + `侧栏点击后 Renderer 未完成 canonical history hydration: session=${sessionId} previous=${JSON.stringify(previousCounts)} actual=${JSON.stringify(Object.fromEntries(methods.map((method) => [method, requestsByMethod[method]?.length ?? 0])))}`, + ); +} + +async function readRendererHistoryRequestCounts(page, sessionId) { + const methods = ["thread/read", "thread/items/list", "thread/turns/list"]; + return Object.fromEntries( + await Promise.all( + methods.map(async (method) => [ + method, + (await readRendererHistoryRequests(page, method, sessionId)).length, + ]), + ), + ); +} + +async function readRendererSessionSwitchSuccessEntries(page, sessionId) { + return page.evaluate((activeSessionId) => { + const entries = window.__LIME_AGENTUI_PERF__?.entries?.() ?? []; + return entries.filter( + (entry) => + entry?.phase === "session.switch.success" && + entry?.sessionId === activeSessionId, + ); + }, sessionId); +} + +async function readRendererSessionSwitchSuccessCount(page, sessionId) { + return (await readRendererSessionSwitchSuccessEntries(page, sessionId)) + .length; +} + +async function waitForRendererSessionSwitchSuccessAfterClick( + page, + sessionId, + previousCount, + options, +) { + const startedAt = Date.now(); + let count = 0; + while (Date.now() - startedAt < options.timeoutMs) { + const entries = await readRendererSessionSwitchSuccessEntries( + page, + sessionId, + ); + count = entries.length; + if (count > previousCount) { + return { count, latest: entries.at(-1) ?? null, previousCount }; + } + await sleep(options.intervalMs); + } + throw new Error( + `侧栏点击后 Renderer session switch 未完成: session=${sessionId} previous=${previousCount} actual=${count}`, + ); +} + +async function switchRuntimeThreadInGuiViaSidebar( + page, + { sourceSessionId, targetSessionId }, + options, +) { + assert( + sourceSessionId && targetSessionId && sourceSessionId !== targetSessionId, + "侧栏冷恢复需要两个不同的 canonical session", + ); + const sourceButton = page.locator( + `[data-testid="app-sidebar-conversation-open"][data-session-id="${sourceSessionId}"]`, + ); + const targetButton = page.locator( + `[data-testid="app-sidebar-conversation-open"][data-session-id="${targetSessionId}"]`, + ); + await sourceButton.waitFor({ state: "visible", timeout: options.timeoutMs }); + await targetButton.waitFor({ state: "visible", timeout: options.timeoutMs }); + const sourceReadCountsBefore = await readRendererHistoryRequestCounts( + page, + sourceSessionId, + ); + const sourceSwitchSuccessCountBefore = + await readRendererSessionSwitchSuccessCount(page, sourceSessionId); + await sourceButton.click(); + const sourceInput = page.locator( + `textarea[name="agent-chat-message"][data-session-id="${sourceSessionId}"]`, + ); + await sourceInput.waitFor({ state: "visible", timeout: options.timeoutMs }); + const sourceHydration = await waitForRendererHistoryHydrationAfterClick( + page, + sourceSessionId, + sourceReadCountsBefore, + options, + ); + const sourceSwitch = await waitForRendererSessionSwitchSuccessAfterClick( + page, + sourceSessionId, + sourceSwitchSuccessCountBefore, + options, + ); + + const targetReadCountsBefore = await readRendererHistoryRequestCounts( + page, + targetSessionId, + ); + const targetSwitchSuccessCountBefore = + await readRendererSessionSwitchSuccessCount(page, targetSessionId); + await targetButton.click(); + const targetInput = page.locator( + `textarea[name="agent-chat-message"][data-session-id="${targetSessionId}"]`, + ); + await targetInput.waitFor({ state: "visible", timeout: options.timeoutMs }); + await page.waitForFunction( + ({ sessionId }) => + document + .querySelector( + `[data-testid="app-sidebar-conversation-open"][data-session-id="${sessionId}"]`, + ) + ?.getAttribute("aria-current") === "page", + { sessionId: targetSessionId }, + { timeout: options.timeoutMs }, + ); + const targetHydration = await waitForRendererHistoryHydrationAfterClick( + page, + targetSessionId, + targetReadCountsBefore, + options, + ); + const targetSwitch = await waitForRendererSessionSwitchSuccessAfterClick( + page, + targetSessionId, + targetSwitchSuccessCountBefore, + options, + ); + + return { + sourceHydration, + sourceSessionId, + sourceSwitch, + targetHydration, + targetSessionId, + targetSwitch, + }; +} + +async function selectRuntimeRouteInRenderer( + page, + options, + { sessionId, route }, +) { + assert( + route?.providerName && route?.model, + "Renderer 模型选择缺少 provider/model route", + ); + const input = page.locator( + `textarea[name="agent-chat-message"][data-session-id="${sessionId}"]`, + ); + const modelSelector = input.locator( + 'xpath=ancestor::*[@data-testid="inputbar-core-container"][1]//*[@data-testid="model-selector"]', + ); + await modelSelector.waitFor({ + state: "visible", + timeout: Math.min(options.timeoutMs, 10_000), + }); + await modelSelector.click(); + + const popover = page.locator('[data-model-selector-popover="true"]'); + await popover.waitFor({ + state: "visible", + timeout: Math.min(options.timeoutMs, 10_000), + }); + const providerButton = popover + .locator("button") + .filter({ hasText: route.providerName }) + .first(); + await providerButton.waitFor({ + state: "visible", + timeout: Math.min(options.timeoutMs, 10_000), + }); + await providerButton.click(); + + const modelButton = popover + .locator("button") + .filter({ hasText: route.model }) + .first(); + await modelButton.waitFor({ + state: "visible", + timeout: Math.min(options.timeoutMs, 10_000), + }); + await modelButton.click(); + await popover.waitFor({ + state: "hidden", + timeout: Math.min(options.timeoutMs, 10_000), + }); + + const selectionHandle = await page.waitForFunction( + ({ expectedModel, expectedProvider, activeSessionId }) => { + const textarea = document.querySelector( + `textarea[name="agent-chat-message"][data-session-id="${activeSessionId}"]`, + ); + const container = textarea?.closest( + '[data-testid="inputbar-core-container"]', + ); + const trigger = container?.querySelector( + '[data-testid="model-selector"]', + ); + const text = trigger?.textContent?.trim() || ""; + const title = trigger?.getAttribute("title") || ""; + return text.includes(expectedModel) && title.includes(expectedProvider) + ? { text, title } + : null; + }, + { + expectedModel: route.model, + expectedProvider: route.providerName, + activeSessionId: sessionId, + }, + { timeout: Math.min(options.timeoutMs, 10_000) }, + ); + const selection = await selectionHandle.jsonValue(); + return { + sessionId, + providerId: route.providerId, + providerName: route.providerName, + model: route.model, + triggerText: selection.text, + triggerTitle: selection.title, + }; +} + +function findPluginV2MentionIdentity(result) { + const thread = result?.thread; + for (const turn of thread?.turns ?? []) { + for (const item of turn?.items ?? []) { + const mention = item?.content?.find( + (part) => + part?.type === "mention" && part?.path === `plugin://${PLUGIN_V2_ID}`, + ); + if (mention) { + return { + mention, + threadId: String(thread?.id || "").trim(), + turnId: String(turn?.id || "").trim(), + userItemId: String(item?.id || "").trim(), + }; + } + } + } + return null; +} + +async function waitForPluginV2GuiTurn( + page, + threadId, + options, + observedMethods, +) { + const startedAt = Date.now(); + let latest = null; + while (Date.now() - startedAt < options.timeoutMs) { + latest = await appServerCallFromPage(page, "thread/read", { + threadId, + includeTurns: true, + }); + observedMethods.add(latest.method); + const identity = findPluginV2MentionIdentity(latest.result); + if ( + identity?.threadId === threadId && + identity.turnId && + identity.userItemId + ) { + return { identity, read: latest }; + } + await sleep(10); + } + throw new Error( + `Claw turn 未投影 plugin://${PLUGIN_V2_ID} mention: ${JSON.stringify(latest?.result)}`, + ); +} + +async function startPluginV2TurnFromGui({ + page, + runtime, + options, + observedMethods, + screenshotPath, +}) { + const guiModelSelection = await selectRuntimeRouteInRenderer(page, options, { + sessionId: runtime.sessionId, + route: runtime.route, + }); + const input = page.locator( + `textarea[name="agent-chat-message"][data-session-id="${runtime.sessionId}"]`, + ); + await input.fill( + "Read the desktop app information, then run the release check through the available MCP tool.", + ); + await page.locator('[data-testid="inputbar-plus-trigger"]').click(); + await page.locator('[data-testid="inputbar-plus-menu"]').waitFor({ + state: "visible", + timeout: options.timeoutMs, + }); + await page.locator('[data-testid="inputbar-plus-plugins"]').click(); + const pluginPanel = page.locator( + '[data-testid="inputbar-plus-panel-plugins"]', + ); + await pluginPanel.waitFor({ state: "visible", timeout: options.timeoutMs }); + const pluginOption = pluginPanel + .locator('[data-testid="inputbar-plugin-option"]') + .filter({ hasText: PLUGIN_V2_DISPLAY_NAME }) + .first(); + await pluginOption.waitFor({ state: "visible", timeout: options.timeoutMs }); + await pluginOption.click(); + const badge = page.locator('[data-testid="inputbar-plugin-badge"]'); + await badge.waitFor({ state: "visible", timeout: options.timeoutMs }); + assert( + (await badge.textContent())?.includes(PLUGIN_V2_DISPLAY_NAME), + "Claw Plugin badge identity 不一致", + ); + assert( + (await input.inputValue()).startsWith(`@${PLUGIN_V2_DISPLAY_NAME}`), + "Claw composer 未写入 Plugin 显式触发前缀", + ); + await page.screenshot({ path: screenshotPath, fullPage: true }); + await page.keyboard.press("Escape"); + await page.keyboard.press("Escape"); + + const dynamicLifecyclePromise = probeDynamicToolLifecycle( + page, + runtime.threadId, + options, + observedMethods, + ); + await page.locator('[data-testid="send-btn"]').last().click(); + await observeAppServerMethodFromTrace( + page, + "turn/start", + options, + observedMethods, + ); + const guiTurn = await waitForPluginV2GuiTurn( + page, + runtime.threadId, + options, + observedMethods, + ); + const dynamicLifecycle = await dynamicLifecyclePromise; + return { + dynamicLifecycle, + guiModelSelection, + guiTurn, + turn: { + method: "turn/start", + result: { turn: { id: guiTurn.identity.turnId } }, + source: "renderer", + }, + turnId: guiTurn.identity.turnId, + userItemId: guiTurn.identity.userItemId, + }; +} + async function startRuntimeTurn( page, - { fixture, observedMethods, options, serverName, workspaceRoot }, + { + fixture, + observedMethods, + options, + pluginV2 = false, + pluginMentionScreenshotPath, + serverName, + workspaceRoot, + }, ) { const expectedToolName = toolName(serverName); const route = await createRepositoryProvider(page, fixture, observedMethods); @@ -458,6 +1436,25 @@ async function startRuntimeTurn( }); observedMethods.add(update.method); const gui = await openRuntimeThreadInGui(page, sessionId, options); + if (pluginV2) { + const guiRuntime = await startPluginV2TurnFromGui({ + page, + runtime: { route, sessionId, threadId }, + options, + observedMethods, + screenshotPath: pluginMentionScreenshotPath, + }); + return { + ...guiRuntime, + expectedToolName, + gui, + route, + sessionId, + start, + threadId, + update, + }; + } const dynamicLifecyclePromise = probeDynamicToolLifecycle( page, threadId, @@ -613,7 +1610,10 @@ function isExactEmptyObject(value) { ); } -function mcpInitializeCapabilityEvidence(ledger) { +function mcpInitializeCapabilityEvidence( + ledger, + { managementInitializeRequired = true } = {}, +) { const accepted = ledger.find( (entry) => entry?.type === "elicitation_result" && @@ -645,11 +1645,17 @@ function mcpInitializeCapabilityEvidence(ledger) { const capabilityMissingCount = ledger.filter( (entry) => entry?.type === "capability_missing", ).length; + const managementConnectionAbsent = !ledger.some( + (entry) => entry?.type === "initialize" && entry?.pid !== accepted?.pid, + ); return { acceptedPid: accepted?.pid ?? null, capabilityMissingCount, managementInitialize: managementInitialize ?? null, - managementElicitationCapabilityAbsent: Boolean(managementInitialize), + managementConnectionAbsent, + managementElicitationCapabilityAbsent: managementInitializeRequired + ? Boolean(managementInitialize) + : managementConnectionAbsent, runtimeCapabilityExact, runtimeInitialize: runtimeInitialize ?? null, runtimeProtocolCurrent: runtimeInitialize?.protocolVersion === "2025-06-18", @@ -663,6 +1669,7 @@ async function waitForCompletion( ledgerPath, options, observedMethods, + { managementInitializeRequired = true } = {}, ) { const startedAt = Date.now(); let latestRead = null; @@ -673,7 +1680,9 @@ async function waitForCompletion( }); observedMethods.add(latestRead.method); const ledger = readJsonLines(ledgerPath); - const capabilityEvidence = mcpInitializeCapabilityEvidence(ledger); + const capabilityEvidence = mcpInitializeCapabilityEvidence(ledger, { + managementInitializeRequired, + }); const serialized = JSON.stringify(latestRead.result || {}); if ( fixture.requests.length >= 2 && @@ -692,7 +1701,7 @@ async function waitForCompletion( ); } -function electronEvidence(traceRaw, observedMethods) { +function electronEvidence(traceRaw, observedMethods, requiredMethods) { const trace = parseInvokeTraceRaw(traceRaw); const methods = Array.from( new Set([ @@ -712,15 +1721,45 @@ function electronEvidence(traceRaw, observedMethods) { legacyMcpCommandsSeen: LEGACY_MCP_COMMANDS.filter((command) => commands.includes(command), ), - missingRequiredMethods: REQUIRED_METHODS.filter( + mockFallbackHitCount: trace.filter( + (entry) => + entry?.mock === true || + entry?.mockFallback === true || + (entry?.command === APP_SERVER_HANDLE_JSON_LINES_COMMAND && + entry?.transport !== "electron-ipc"), + ).length, + pluginWorkerCommandsSeen: LEGACY_PLUGIN_WORKER_COMMANDS.filter((command) => + commands.includes(command), + ), + missingRequiredMethods: requiredMethods.filter( (method) => !methods.includes(method), ), requestMethods: methods, }; } -async function run() { - const options = parseArgs(process.argv.slice(2)); +export async function run({ pluginV2 = false } = {}) { + const defaults = pluginV2 + ? { + ...DEFAULTS, + evidenceDir: path.join( + process.cwd(), + ".lime", + "qc", + "gui-evidence", + "plugin-v2-current-electron-fixture", + ), + prefix: "plugin-v2-current-electron-fixture", + } + : DEFAULTS; + const options = parseArgs(process.argv.slice(2), defaults); + const requiredMethods = pluginV2 + ? PLUGIN_V2_REQUIRED_METHODS + : REQUIRED_METHODS; + ensureElectronFixtureBuild({ + logPrefix: LOG_PREFIX, + rootDir: process.cwd(), + }); fs.mkdirSync(options.evidenceDir, { recursive: true }); const summaryPath = path.join( options.evidenceDir, @@ -731,6 +1770,30 @@ async function run() { options.evidenceDir, `${options.prefix}.png`, ); + const appCenterScreenshotPath = path.join( + options.evidenceDir, + `${options.prefix}-app-center-bundled.png`, + ); + const installReviewScreenshotPath = path.join( + options.evidenceDir, + `${options.prefix}-install-review.png`, + ); + const pluginMentionScreenshotPath = path.join( + options.evidenceDir, + `${options.prefix}-claw-plugin-mention.png`, + ); + const uninstallScreenshotPath = path.join( + options.evidenceDir, + `${options.prefix}-uninstall.png`, + ); + const postUninstallHistoryScreenshotPath = path.join( + options.evidenceDir, + `${options.prefix}-post-uninstall-history.png`, + ); + const coldRestoreScreenshotPath = path.join( + options.evidenceDir, + `${options.prefix}-cold-restore.png`, + ); const failureScreenshotPath = path.join( options.evidenceDir, `${options.prefix}-failure.png`, @@ -739,16 +1802,80 @@ async function run() { const appServerBinary = resolveDevAppServerBinary({ env: runtimeEnv.env, repoRoot: process.cwd(), - forceBuild: false, + forceBuild: pluginV2, }); const appServerEnv = resolveElectronAppServerRuntimeEnv({ env: { ...runtimeEnv.env, APP_SERVER_BIN: appServerBinary }, }); + const startedAt = new Date().toISOString(); const summary = { ok: false, - checkedAt: new Date().toISOString(), + checkedAt: startedAt, + startedAt, + completedAt: null, + appVersion: null, + platform: null, + arch: null, + electronVersion: null, + electronLaunchCount: 0, + electronMainProcessPids: [], + repositoryCommit: readRepositoryCommit(process.cwd()), backendMode: "runtime", proofLevel: "Gate B", + scenario: pluginV2 ? "plugin-v2-mcp-runtime" : "mcp-elicitation", + pluginId: pluginV2 ? PLUGIN_V2_ID : null, + pluginMarketplaceId: null, + pluginSource: null, + pluginVersion: null, + pluginRuntimeServerName: pluginV2 ? pluginV2ServerName() : null, + pluginContentDigest: null, + sessionId: null, + threadId: null, + turnId: null, + userItemId: null, + toolItemId: null, + toolCallId: pluginV2 ? MCP_TOOL_CALL_ID : null, + surfaceId: null, + resourceUri: pluginV2 ? PLUGIN_V2_MCP_APP_RESOURCE_URI : null, + appCenterBundledListVisible: false, + appCenterBundledIdentityStable: false, + appCenterInstallReviewVisible: false, + appCenterInstallCompleted: false, + appCenterEnableToggleCompleted: false, + disabledBoundaryCompleted: false, + disabledNewSessionId: null, + disabledNewThreadId: null, + disabledNewTurnId: null, + disabledPluginPickerBlocked: false, + disabledProviderToolAbsent: false, + disabledThreadPluginItemsAbsent: false, + enabledNewBoundaryCapabilityRestored: false, + nativeDirectoryDialogObserved: false, + clawPluginPickerVisible: false, + clawPluginBadgeVisible: false, + clawFixtureModelSelected: false, + clawStructuredMentionObserved: false, + clawThreadTurnItemIdentityStable: false, + pluginMentionPath: pluginV2 ? `plugin://${PLUGIN_V2_ID}` : null, + pluginMentionUserItemId: null, + uninstallViaAppCenterCompleted: false, + installedProjectionClearedAfterUninstall: false, + historyReadableAfterUninstall: false, + historyMcpRuntimeNotRestartedAfterUninstall: false, + historyProviderNotReexecutedAfterUninstall: false, + coldRestoreCompleted: false, + coldRestoreInstalledProjectionRecovered: false, + coldRestoreIdentityStable: false, + coldRestoreExplicitSurfaceOpen: false, + coldRestoreCanonicalHydrationViaSidebar: false, + coldRestoreMcpProcessRestarted: false, + coldRestoreProviderNotReexecuted: false, + coldRestoreToolNotReexecuted: false, + coldRestoreScreenshot: pluginV2 ? coldRestoreScreenshotPath : null, + mcpAppHistoryUnavailableAfterUninstall: false, + historyToolNotReexecutedAfterUninstall: false, + pluginWorkerHitCount: null, + productionMockFallbackHitCount: null, capabilityAdvertisementRequired: true, capabilityMissingCount: null, managementElicitationCapabilityAbsent: false, @@ -762,15 +1889,30 @@ async function run() { rootDialogAbsent: false, mcpLedgerAccepted: false, providerFinalTextObserved: false, + mcpAppCanonicalIdentityStable: false, + mcpAppHtmlLoadCount: 0, + mcpAppResourceReadCount: 0, + mcpAppResourceUri: pluginV2 ? PLUGIN_V2_MCP_APP_RESOURCE_URI : null, + mcpAppRestoredAfterReload: false, + mcpAppRightSurfaceVisible: false, + mcpAppToolCallCount: 0, + mcpAppWebContentsMarker: null, dynamicToolProviderResultObserved: false, dynamicToolCanonicalCompleted: false, dynamicToolStartedObserved: false, dynamicToolRequestHiddenFromRenderer: false, providerRequestCount: 0, consoleErrors: [], - missingRequiredMethods: [...REQUIRED_METHODS], + missingRequiredMethods: [...requiredMethods], legacyMcpCommandsSeen: [], screenshot: null, + appCenterScreenshot: pluginV2 ? appCenterScreenshotPath : null, + installReviewScreenshot: pluginV2 ? installReviewScreenshotPath : null, + pluginMentionScreenshot: pluginV2 ? pluginMentionScreenshotPath : null, + uninstallScreenshot: pluginV2 ? uninstallScreenshotPath : null, + postUninstallHistoryScreenshot: pluginV2 + ? postUninstallHistoryScreenshotPath + : null, summary: summaryPath, rawEvidence: rawPath, tempRoot: options.keepTemp ? runtimeEnv.tempRoot : null, @@ -781,13 +1923,15 @@ async function run() { let app = null; let page = null; let fixture = null; + let disabledBoundaryFixture = null; let mcpFixture = null; let server = null; + let plugin = null; let dynamicToolLifecycleTraceRaw = ""; try { logStage("start-provider-fixture"); - const serverName = makeServerName(); + const serverName = pluginV2 ? pluginV2ServerName() : makeServerName(); const expectedToolName = toolName(serverName); fixture = await startOpenAiCompatibleFixtureServer({ scriptedResponses: [ @@ -799,7 +1943,7 @@ async function run() { }, { type: "tool_call", - id: "call-mcp-elicitation-release-check", + id: MCP_TOOL_CALL_ID, name: expectedToolName, arguments: { release: "gate-b" }, }, @@ -807,6 +1951,9 @@ async function run() { ], }); mcpFixture = writeElicitationFixture(); + if (pluginV2) { + plugin = preparePluginV2Package(mcpFixture); + } logStage("launch-electron"); const handle = await launchElectronFixture({ @@ -818,42 +1965,127 @@ async function run() { }); app = handle.app; page = handle.page; + const electronRuntime = await readElectronRuntime(app); + summary.appVersion = electronRuntime.appVersion; + summary.arch = electronRuntime.arch; + summary.electronVersion = electronRuntime.electronVersion; + summary.platform = electronRuntime.platform; + summary.electronLaunchCount = 1; + summary.electronMainProcessPids = [electronRuntime.pid]; + raw.electronRuntime = sanitizeJson(electronRuntime); summary.electronPreloadBridge = handle.rendererSnapshot.electron && handle.rendererSnapshot.hasInvokeBridge; - logStage("create-and-start-mcp-server"); - const serverId = `mcp-elicitation-${Date.now()}-${process.pid}`; - const created = await appServerCallFromPage(page, "mcpServer/create", { - server: { - id: serverId, - name: serverName, - description: "MCP elicitation Gate B fixture", - server_config: { - command: process.execPath, - args: [mcpFixture.serverPath, mcpFixture.ledgerPath], - cwd: mcpFixture.root, - timeout: 10, - tool_timeout: 60, + if (pluginV2) { + await installPluginV2EmbeddedBrowserLifecycleCapture(page); + logStage("open-plugin-v2-app-center"); + const appCenterCatalog = await openPluginV2AppCenter( + page, + options, + observedMethods, + appCenterScreenshotPath, + ); + summary.appCenterBundledListVisible = true; + summary.appCenterBundledIdentityStable = true; + raw.appCenterCatalog = sanitizeJson(appCenterCatalog); + + logStage("install-plugin-v2-from-app-center"); + const pluginLifecycle = await installPluginV2FromAppCenter({ + app, + page, + plugin, + options, + observedMethods, + screenshotPath: installReviewScreenshotPath, + }); + summary.appCenterInstallReviewVisible = true; + summary.nativeDirectoryDialogObserved = true; + summary.appCenterInstallCompleted = true; + const installedSummary = pluginLifecycle.read.result?.plugin?.summary; + summary.pluginMarketplaceId = installedSummary?.marketplaceId ?? null; + summary.pluginSource = installedSummary?.source ?? null; + summary.pluginVersion = installedSummary?.version ?? null; + summary.pluginContentDigest = installedSummary?.contentDigest ?? null; + raw.pluginLifecycle = sanitizeJson(pluginLifecycle); + + logStage("toggle-plugin-v2-from-app-center"); + const disabled = await setPluginV2EnabledFromAppCenter( + page, + plugin.id, + false, + options, + observedMethods, + ); + logStage("verify-plugin-v2-disabled-new-thread-boundary"); + disabledBoundaryFixture = await startOpenAiCompatibleFixtureServer({ + scriptedResponses: [ + { type: "text", content: DISABLED_BOUNDARY_FINAL_TEXT }, + ], + }); + const disabledBoundary = await verifyPluginV2DisabledNewThreadBoundary({ + page, + fixture: disabledBoundaryFixture, + plugin, + options, + observedMethods, + }); + summary.disabledBoundaryCompleted = true; + summary.disabledNewSessionId = disabledBoundary.sessionId; + summary.disabledNewThreadId = disabledBoundary.threadId; + summary.disabledNewTurnId = disabledBoundary.turnId; + summary.disabledPluginPickerBlocked = + disabledBoundary.pluginPickerBlocked; + summary.disabledProviderToolAbsent = disabledBoundary.providerToolAbsent; + summary.disabledThreadPluginItemsAbsent = + disabledBoundary.threadPluginItemsAbsent; + raw.pluginDisabledBoundary = sanitizeJson(disabledBoundary); + + const enabled = await setPluginV2EnabledFromAppCenter( + page, + plugin.id, + true, + options, + observedMethods, + ); + summary.appCenterEnableToggleCompleted = true; + raw.pluginEnableToggle = sanitizeJson({ disabled, enabled }); + } else { + logStage("create-and-start-mcp-server"); + const serverId = `mcp-elicitation-${Date.now()}-${process.pid}`; + const created = await appServerCallFromPage(page, "mcpServer/create", { + server: { + id: serverId, + name: serverName, + description: "MCP elicitation Gate B fixture", + server_config: { + command: process.execPath, + args: [mcpFixture.serverPath, mcpFixture.ledgerPath], + cwd: mcpFixture.root, + timeout: 10, + tool_timeout: 60, + }, + enabled_lime: true, + enabled_claude: false, + enabled_codex: false, + enabled_gemini: false, + created_at: Date.now(), }, - enabled_lime: true, - enabled_claude: false, - enabled_codex: false, - enabled_gemini: false, - created_at: Date.now(), - }, - }); - observedMethods.add(created.method); - server = { id: serverId, name: serverName }; - raw.mcpServerCreate = sanitizeJson(created); - const startedServer = await appServerCallFromPage(page, "mcpServer/start", { - name: serverName, - }); - observedMethods.add(startedServer.method); - raw.mcpServerStart = sanitizeJson(startedServer); - raw.mcpToolList = sanitizeJson( - await waitForTool(page, expectedToolName, options, observedMethods), - ); + }); + observedMethods.add(created.method); + server = { id: serverId, name: serverName }; + raw.mcpServerCreate = sanitizeJson(created); + const startedServer = await appServerCallFromPage( + page, + "mcpServer/start", + { name: serverName }, + ); + observedMethods.add(startedServer.method); + raw.mcpServerStart = sanitizeJson(startedServer); + raw.mcpToolList = sanitizeJson( + await waitForTool(page, expectedToolName, options, observedMethods), + ); + } logStage("start-agent-turn"); const workspace = await ensureWorkspace(page, observedMethods); @@ -862,11 +2094,36 @@ async function run() { fixture, observedMethods, options, + pluginV2, + pluginMentionScreenshotPath, serverName, workspaceRoot: workspace.rootPath, }); raw.runtime = sanitizeJson(runtime); + summary.sessionId = runtime.sessionId ?? null; + summary.threadId = runtime.threadId ?? null; + summary.turnId = runtime.turnId ?? null; + summary.userItemId = runtime.userItemId ?? null; raw.dynamicToolLifecycle = sanitizeJson(runtime.dynamicLifecycle); + if (pluginV2) { + summary.clawPluginPickerVisible = true; + summary.clawPluginBadgeVisible = true; + summary.clawFixtureModelSelected = + runtime.guiModelSelection?.providerId === runtime.route.providerId && + runtime.guiModelSelection?.model === runtime.route.model; + summary.clawStructuredMentionObserved = true; + summary.clawThreadTurnItemIdentityStable = + runtime.guiTurn?.identity?.threadId === runtime.threadId && + runtime.guiTurn?.identity?.turnId === runtime.turnId && + Boolean(runtime.userItemId); + summary.pluginMentionUserItemId = runtime.userItemId ?? null; + } + raw.providerRequestsBeforeElicitation = sanitizeJson( + providerRequestSummary(fixture.requests), + ); + raw.mcpLedgerBeforeElicitation = sanitizeJson( + readJsonLines(mcpFixture.ledgerPath), + ); logStage("submit-renderer-form"); const { form, rootDialogAbsent } = await waitForElicitationForm( @@ -899,6 +2156,7 @@ async function run() { mcpFixture.ledgerPath, options, observedMethods, + { managementInitializeRequired: !pluginV2 }, ); raw.completion = sanitizeJson(completion.read); raw.mcpLedger = sanitizeJson(completion.ledger); @@ -916,6 +2174,10 @@ async function run() { completion.capabilityEvidence.runtimeInitialize?.protocolVersion ?? null; const providerRequests = providerRequestSummary(fixture.requests); raw.providerRequests = sanitizeJson(providerRequests); + if (pluginV2) { + summary.enabledNewBoundaryCapabilityRestored = + providerRequests[0]?.toolNames?.includes(expectedToolName) === true; + } summary.providerRequestCount = providerRequests.length; summary.dynamicToolProviderResultObserved = JSON.stringify( fixture.requests.slice(1), @@ -941,14 +2203,349 @@ async function run() { '"method":"item/tool/call"', ); raw.dynamicToolItems = sanitizeJson(dynamicItems); - const evidence = electronEvidence(traceRaw, observedMethods); + let evidence = electronEvidence(traceRaw, observedMethods, requiredMethods); summary.appServerHandleJsonLinesSeen = evidence.appServerHandleJsonLinesSeen; summary.missingRequiredMethods = evidence.missingRequiredMethods; summary.legacyMcpCommandsSeen = evidence.legacyMcpCommandsSeen; + summary.pluginWorkerHitCount = evidence.pluginWorkerCommandsSeen.length; + summary.productionMockFallbackHitCount = evidence.mockFallbackHitCount; raw.electronEvidence = sanitizeJson(evidence); summary.consoleErrors = [...consoleErrors]; + if (pluginV2) { + logStage("verify-plugin-v2-mcp-app-surface"); + const completionMention = findPluginV2MentionIdentity( + completion.read.result, + ); + assert( + completionMention?.threadId === runtime.threadId && + completionMention?.turnId === runtime.turnId && + completionMention?.userItemId === runtime.userItemId, + "Claw structured Plugin mention 未保持 Thread/Turn/Item identity", + ); + const mcpAppItems = findPluginV2McpAppItems(completion.read.result); + assert( + mcpAppItems.length === 1, + `thread/read 未观察到唯一 MCP App item: ${JSON.stringify(mcpAppItems)}`, + ); + runtime.mcpAppItemId = String(mcpAppItems[0]?.id || "").trim(); + assert(runtime.mcpAppItemId, "MCP App item 缺少 canonical id"); + summary.toolItemId = runtime.mcpAppItemId; + await openRuntimeThreadInGui(page, runtime.sessionId, options); + const firstSurface = await waitForPluginV2McpAppSurface({ + app, + page, + options, + runtime, + }); + assert( + firstSurface.traceEvidence.resourceReadCount === 2 && + firstSurface.traceEvidence.htmlLoadCount === 2, + "首次显式恢复前后的 MCP App resource/HTML load 计数异常", + ); + summary.mcpAppRightSurfaceVisible = true; + summary.mcpAppWebContentsMarker = PLUGIN_V2_MCP_APP_MARKER; + summary.surfaceId = firstSurface.viewId; + raw.mcpAppFirstSurface = sanitizeJson(firstSurface); + + logStage("reload-plugin-v2-mcp-app-surface"); + await openRuntimeThreadInGui(page, runtime.sessionId, options); + const restoredRead = await appServerCallFromPage(page, "thread/read", { + threadId: runtime.threadId, + includeTurns: true, + }); + observedMethods.add(restoredRead.method); + const restoredItems = findPluginV2McpAppItems(restoredRead.result); + assert(restoredItems.length === 1, "reload 后 MCP App item 数量不稳定"); + assert( + restoredItems[0]?.id === runtime.mcpAppItemId && + restoredItems[0]?.mcpAppResourceUri === + PLUGIN_V2_MCP_APP_RESOURCE_URI && + restoredItems[0]?.pluginId === PLUGIN_V2_ID, + "reload 后 Plugin/item/resource identity 漂移", + ); + const restoredSurface = await waitForPluginV2McpAppSurface({ + app, + page, + options, + runtime, + }); + assert( + restoredSurface.traceEvidence.resourceReadCount === 3 && + restoredSurface.traceEvidence.htmlLoadCount === 3, + "Renderer reload 后 MCP App resource/HTML load 计数异常", + ); + summary.mcpAppRestoredAfterReload = true; + summary.mcpAppCanonicalIdentityStable = + restoredSurface.containerId === firstSurface.containerId && + restoredSurface.viewId === firstSurface.viewId; + raw.mcpAppRestoredRead = sanitizeJson(restoredRead); + raw.mcpAppRestoredSurface = sanitizeJson(restoredSurface); + await page.screenshot({ path: screenshotPath, fullPage: true }); + + const preColdRestoreLedger = readJsonLines(mcpFixture.ledgerPath); + const preColdRestoreResourceReadCount = preColdRestoreLedger.filter( + (entry) => + entry?.type === "resource_read" && + entry?.uri === PLUGIN_V2_MCP_APP_RESOURCE_URI, + ).length; + const preColdRestoreToolCallCount = preColdRestoreLedger.filter( + (entry) => entry?.type === "tool_call" && entry?.name === TOOL_SUFFIX, + ).length; + const preColdRestoreProviderRequestCount = fixture.requests.length; + + logStage("cold-restart-plugin-v2-history"); + await closeElectronFixture({ app }); + app = null; + page = null; + const coldHandle = await launchElectronFixture({ + options, + runtimeEnv, + appServerEnv, + consoleErrors, + backendMode: "runtime", + }); + app = coldHandle.app; + page = coldHandle.page; + await installPluginV2EmbeddedBrowserLifecycleCapture(page); + const coldElectronRuntime = await readElectronRuntime(app); + summary.electronLaunchCount = 2; + summary.electronMainProcessPids.push(coldElectronRuntime.pid); + assert( + new Set(summary.electronMainProcessPids).size === 2, + "cold restore 未启动新的 Electron 主进程", + ); + const coldInstalled = await waitForPluginInstalledState( + page, + plugin.id, + true, + options, + observedMethods, + ); + summary.coldRestoreInstalledProjectionRecovered = true; + const coldNavigation = await switchRuntimeThreadInGuiViaSidebar( + page, + { + sourceSessionId: summary.disabledNewSessionId, + targetSessionId: runtime.sessionId, + }, + options, + ); + summary.coldRestoreCanonicalHydrationViaSidebar = true; + raw.coldRestoreNavigation = sanitizeJson(coldNavigation); + const coldRead = await appServerCallFromPage(page, "thread/read", { + threadId: runtime.threadId, + includeTurns: true, + }); + observedMethods.add(coldRead.method); + const coldMention = findPluginV2MentionIdentity(coldRead.result); + const coldItems = findPluginV2McpAppItems(coldRead.result); + assert( + coldMention?.threadId === runtime.threadId && + coldMention?.turnId === runtime.turnId && + coldMention?.userItemId === runtime.userItemId && + coldItems.length === 1 && + coldItems[0]?.id === runtime.mcpAppItemId && + coldItems[0]?.pluginId === PLUGIN_V2_ID, + "cold restore 后 Plugin Thread/Turn/Item identity 漂移", + ); + const coldSurfaceTab = page.locator( + '[data-testid="workspace-right-surface-tab-appSurface"]', + ); + await coldSurfaceTab.waitFor({ + state: "visible", + timeout: options.timeoutMs, + }); + await coldSurfaceTab.click(); + summary.coldRestoreExplicitSurfaceOpen = true; + const coldSurface = await waitForPluginV2McpAppSurface({ + app, + page, + options, + runtime, + }); + assert( + coldSurface.containerId === firstSurface.containerId && + coldSurface.viewId === firstSurface.viewId, + "cold restore 后 Plugin surface identity 漂移", + ); + const coldLedger = readJsonLines(mcpFixture.ledgerPath); + const coldResourceReadCount = coldLedger.filter( + (entry) => + entry?.type === "resource_read" && + entry?.uri === PLUGIN_V2_MCP_APP_RESOURCE_URI, + ).length; + const coldToolCallCount = coldLedger.filter( + (entry) => entry?.type === "tool_call" && entry?.name === TOOL_SUFFIX, + ).length; + const runtimeMcpProcessIds = Array.from( + new Set( + coldLedger + .filter((entry) => entry?.type === "initialize") + .map((entry) => entry?.pid) + .filter((pid) => Number.isInteger(pid)), + ), + ); + assert( + coldResourceReadCount - preColdRestoreResourceReadCount === + coldSurface.traceEvidence.resourceReadCount && + coldSurface.traceEvidence.resourceReadCount === + coldSurface.traceEvidence.htmlLoadCount, + "cold restore 后 MCP resource/HTML load 未一一对应", + ); + assert( + runtimeMcpProcessIds.length === 2, + `cold restore 未重启 Plugin MCP runtime process: ${JSON.stringify(runtimeMcpProcessIds)}`, + ); + assert( + fixture.requests.length === preColdRestoreProviderRequestCount && + coldToolCallCount === preColdRestoreToolCallCount && + coldToolCallCount === 1, + "cold restore 偷偷重跑了 provider turn 或 MCP tool", + ); + summary.coldRestoreCompleted = true; + summary.coldRestoreIdentityStable = true; + summary.coldRestoreMcpProcessRestarted = true; + summary.coldRestoreProviderNotReexecuted = true; + summary.coldRestoreToolNotReexecuted = true; + summary.mcpAppCanonicalIdentityStable = + summary.mcpAppCanonicalIdentityStable && + coldSurface.containerId === firstSurface.containerId && + coldSurface.viewId === firstSurface.viewId; + raw.coldElectronRuntime = sanitizeJson(coldElectronRuntime); + raw.coldRestoreInstalled = sanitizeJson(coldInstalled); + raw.mcpAppColdRestoreRead = sanitizeJson(coldRead); + raw.mcpAppColdRestoreSurface = sanitizeJson(coldSurface); + raw.mcpAppColdRestoreLedger = sanitizeJson(coldLedger); + await page.screenshot({ + path: coldRestoreScreenshotPath, + fullPage: true, + }); + + logStage("uninstall-plugin-v2-from-app-center"); + await uninstallPluginV2FromAppCenter({ + page, + pluginId: plugin.id, + options, + observedMethods, + screenshotPath: uninstallScreenshotPath, + }); + summary.uninstallViaAppCenterCompleted = true; + summary.installedProjectionClearedAfterUninstall = true; + + logStage("restore-plugin-v2-history-after-uninstall"); + await openRuntimeThreadInGui(page, runtime.sessionId, options); + const postUninstallRead = await appServerCallFromPage( + page, + "thread/read", + { + threadId: runtime.threadId, + includeTurns: true, + }, + ); + observedMethods.add(postUninstallRead.method); + const postUninstallMention = findPluginV2MentionIdentity( + postUninstallRead.result, + ); + const postUninstallItems = findPluginV2McpAppItems( + postUninstallRead.result, + ); + assert( + postUninstallMention?.threadId === runtime.threadId && + postUninstallMention?.turnId === runtime.turnId && + postUninstallMention?.userItemId === runtime.userItemId, + "卸载后 Plugin mention 的 Thread/Turn/Item identity 漂移", + ); + assert( + postUninstallItems.length === 1 && + postUninstallItems[0]?.id === runtime.mcpAppItemId && + postUninstallItems[0]?.pluginId === PLUGIN_V2_ID, + "卸载后 Plugin MCP 历史 item 不可读或 identity 漂移", + ); + summary.historyReadableAfterUninstall = true; + const postUninstallSurface = + await waitForPluginV2McpAppHistoryUnavailable({ + app, + page, + options, + runtime, + }); + assert( + postUninstallSurface.traceEvidence.resourceReadCount === + coldSurface.traceEvidence.resourceReadCount && + postUninstallSurface.traceEvidence.htmlLoadCount === + coldSurface.traceEvidence.htmlLoadCount, + "卸载后历史 surface 不得重新读取 MCP resource 或加载 HTML", + ); + summary.mcpAppHistoryUnavailableAfterUninstall = true; + summary.mcpAppCanonicalIdentityStable = + summary.mcpAppCanonicalIdentityStable && + postUninstallSurface.containerId === firstSurface.containerId && + postUninstallSurface.viewId === firstSurface.viewId; + summary.mcpAppHtmlLoadCount = + restoredSurface.traceEvidence.htmlLoadCount + + coldSurface.traceEvidence.htmlLoadCount; + const finalLedger = readJsonLines(mcpFixture.ledgerPath); + summary.mcpAppResourceReadCount = finalLedger.filter( + (entry) => + entry?.type === "resource_read" && + entry?.uri === PLUGIN_V2_MCP_APP_RESOURCE_URI, + ).length; + summary.mcpAppToolCallCount = finalLedger.filter( + (entry) => entry?.type === "tool_call" && entry?.name === TOOL_SUFFIX, + ).length; + summary.historyToolNotReexecutedAfterUninstall = + summary.mcpAppToolCallCount === 1; + const finalRuntimeMcpProcessIds = Array.from( + new Set( + finalLedger + .filter((entry) => entry?.type === "initialize") + .map((entry) => entry?.pid) + .filter((pid) => Number.isInteger(pid)), + ), + ); + summary.historyMcpRuntimeNotRestartedAfterUninstall = + finalRuntimeMcpProcessIds.length === runtimeMcpProcessIds.length; + summary.historyProviderNotReexecutedAfterUninstall = + fixture.requests.length === preColdRestoreProviderRequestCount; + assert( + summary.mcpAppResourceReadCount === summary.mcpAppHtmlLoadCount && + summary.mcpAppResourceReadCount === coldResourceReadCount, + "跨进程与卸载后恢复的 MCP resource/HTML 累计计数异常", + ); + assert( + summary.historyMcpRuntimeNotRestartedAfterUninstall && + summary.historyProviderNotReexecutedAfterUninstall && + summary.historyToolNotReexecutedAfterUninstall, + "卸载后查看历史不得重启 MCP runtime、provider 或 tool", + ); + raw.mcpAppPostUninstallRead = sanitizeJson(postUninstallRead); + raw.mcpAppPostUninstallSurface = sanitizeJson(postUninstallSurface); + raw.mcpAppFinalLedger = sanitizeJson(finalLedger); + await page.screenshot({ + path: postUninstallHistoryScreenshotPath, + fullPage: true, + }); + + const finalTraceRaw = + (await page.evaluate(() => + window.localStorage.getItem("lime_invoke_trace_buffer_v1"), + )) || ""; + evidence = electronEvidence( + finalTraceRaw, + observedMethods, + requiredMethods, + ); + summary.appServerHandleJsonLinesSeen = + evidence.appServerHandleJsonLinesSeen; + summary.missingRequiredMethods = evidence.missingRequiredMethods; + summary.legacyMcpCommandsSeen = evidence.legacyMcpCommandsSeen; + summary.pluginWorkerHitCount = evidence.pluginWorkerCommandsSeen.length; + summary.productionMockFallbackHitCount = evidence.mockFallbackHitCount; + raw.electronEvidence = sanitizeJson(evidence); + } + assert(summary.electronPreloadBridge, "Electron preload bridge 未就绪"); assert( summary.appServerHandleJsonLinesSeen, @@ -962,6 +2559,122 @@ async function run() { summary.legacyMcpCommandsSeen.length === 0, `观察到 legacy MCP facade: ${summary.legacyMcpCommandsSeen.join(", ")}`, ); + if (pluginV2) { + assert( + summary.appVersion && + summary.platform && + summary.arch && + summary.electronVersion && + summary.repositoryCommit, + "Gate B evidence 缺少应用版本、平台、Electron 或 commit", + ); + assert( + summary.electronLaunchCount === 2 && + summary.electronMainProcessPids.length === 2 && + summary.coldRestoreCompleted && + summary.coldRestoreInstalledProjectionRecovered && + summary.coldRestoreIdentityStable && + summary.coldRestoreExplicitSurfaceOpen && + summary.coldRestoreCanonicalHydrationViaSidebar && + summary.coldRestoreMcpProcessRestarted && + summary.coldRestoreProviderNotReexecuted && + summary.coldRestoreToolNotReexecuted, + "Plugin cold restore Gate B 未通过", + ); + assert( + summary.pluginId === PLUGIN_V2_ID && + summary.pluginMarketplaceId === "local" && + summary.pluginSource === "local" && + summary.pluginVersion === plugin.version && + String(summary.pluginContentDigest || "").startsWith("sha256:"), + "Gate B evidence 缺少稳定 Plugin source/version/digest identity", + ); + assert( + summary.sessionId === runtime.sessionId && + summary.threadId === runtime.threadId && + summary.turnId === runtime.turnId && + summary.userItemId === runtime.userItemId && + summary.toolItemId === runtime.mcpAppItemId && + summary.toolCallId === MCP_TOOL_CALL_ID && + summary.surfaceId && + summary.resourceUri === PLUGIN_V2_MCP_APP_RESOURCE_URI, + "Gate B evidence 缺少 Thread/Turn/Item/tool/surface identity", + ); + assert( + summary.appCenterBundledListVisible && + summary.appCenterBundledIdentityStable, + "App Center bundled Plugin 列表或 identity 未通过", + ); + assert( + summary.nativeDirectoryDialogObserved && + summary.appCenterInstallReviewVisible && + summary.appCenterInstallCompleted && + summary.appCenterEnableToggleCompleted, + "App Center 目录安装/启停用户流程未通过", + ); + assert( + summary.disabledBoundaryCompleted && + summary.disabledNewSessionId && + summary.disabledNewThreadId && + summary.disabledNewTurnId && + summary.disabledPluginPickerBlocked && + summary.disabledProviderToolAbsent && + summary.disabledThreadPluginItemsAbsent && + summary.enabledNewBoundaryCapabilityRestored, + "Plugin 禁用新 Thread 隔离或重新启用边界未通过", + ); + assert( + summary.clawPluginPickerVisible && + summary.clawPluginBadgeVisible && + summary.clawFixtureModelSelected && + summary.clawStructuredMentionObserved && + summary.clawThreadTurnItemIdentityStable, + "Claw Plugin picker/mention identity 未通过", + ); + assert( + summary.uninstallViaAppCenterCompleted && + summary.installedProjectionClearedAfterUninstall && + summary.historyReadableAfterUninstall && + summary.mcpAppHistoryUnavailableAfterUninstall && + summary.historyMcpRuntimeNotRestartedAfterUninstall && + summary.historyProviderNotReexecutedAfterUninstall && + summary.historyToolNotReexecutedAfterUninstall, + "App Center 卸载或卸载后历史恢复未通过", + ); + assert( + !evidence.requestMethods.includes("mcpServer/create") && + !evidence.requestMethods.includes("mcpServer/start"), + "Plugin v2 MCP 不得通过管理面 mcpServer/create/start 注入", + ); + assert( + completion.capabilityEvidence.managementConnectionAbsent, + "Plugin v2 MCP Gate B 不得额外启动 management MCP connection", + ); + assert( + summary.mcpAppRightSurfaceVisible, + "Plugin MCP App 右侧面板不可见", + ); + assert( + summary.mcpAppRestoredAfterReload, + "Plugin MCP App reload 后未恢复", + ); + assert( + summary.mcpAppCanonicalIdentityStable, + "Plugin MCP App canonical identity 不稳定", + ); + assert( + summary.mcpAppResourceReadCount >= 4, + `Plugin MCP App resource read 次数异常: ${summary.mcpAppResourceReadCount}`, + ); + assert( + summary.mcpAppHtmlLoadCount === summary.mcpAppResourceReadCount, + `Plugin MCP App HTML load 次数异常: ${summary.mcpAppHtmlLoadCount}`, + ); + assert( + summary.mcpAppToolCallCount === 1, + `reload 不得重复执行旧 MCP tool action: ${summary.mcpAppToolCallCount}`, + ); + } assert( providerRequests[0]?.toolNames?.includes(DYNAMIC_TOOL_NAME), `首个 provider request 未携带 Electron dynamic tool: ${DYNAMIC_TOOL_NAME}`, @@ -1024,6 +2737,11 @@ async function run() { consoleErrors.length === 0, `Renderer console error: ${JSON.stringify(consoleErrors)}`, ); + assert(summary.pluginWorkerHitCount === 0, "观察到旧 Plugin worker 命中"); + assert( + summary.productionMockFallbackHitCount === 0, + "观察到 production mock fallback 命中", + ); summary.ok = true; } catch (error) { summary.error = sanitizeText( @@ -1038,11 +2756,19 @@ async function run() { } throw error; } finally { + summary.completedAt = new Date().toISOString(); writeJsonFile(rawPath, raw); writeJsonFile(summaryPath, summary); - await cleanupMcpServer(page, server); + if (pluginV2) { + await cleanupPluginV2(page, plugin); + } else { + await cleanupMcpServer(page, server); + } await closeElectronFixture({ app }); if (fixture) await fixture.close().catch(() => undefined); + if (disabledBoundaryFixture) { + await disabledBoundaryFixture.close().catch(() => undefined); + } if (mcpFixture && !options.keepTemp) { fs.rmSync(mcpFixture.root, { recursive: true, force: true }); } @@ -1053,9 +2779,11 @@ async function run() { console.log(`${LOG_PREFIX} pass summary=${summaryPath}`); } -run().catch((error) => { - console.error( - `${LOG_PREFIX} failed: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exitCode = 1; -}); +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + run().catch((error) => { + console.error( + `${LOG_PREFIX} failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + }); +} diff --git a/scripts/electron/plugin-v2-current-electron-fixture.mjs b/scripts/electron/plugin-v2-current-electron-fixture.mjs new file mode 100644 index 000000000..2973ba241 --- /dev/null +++ b/scripts/electron/plugin-v2-current-electron-fixture.mjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +import process from "node:process"; +import { run } from "./mcp-elicitation-gate-b.mjs"; + +run({ pluginV2: true }).catch((error) => { + console.error( + `[smoke:plugin-v2-current-electron-fixture] failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + process.exitCode = 1; +}); diff --git a/scripts/electron/plugin-v2-current-electron-fixture.test.mjs b/scripts/electron/plugin-v2-current-electron-fixture.test.mjs new file mode 100644 index 000000000..2bf7c70c5 --- /dev/null +++ b/scripts/electron/plugin-v2-current-electron-fixture.test.mjs @@ -0,0 +1,118 @@ +import fs from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("Plugin v2 current Electron fixture guard", () => { + it("uses the real Plugin v2 install and runtime MCP lifecycle", () => { + const wrapper = fs.readFileSync( + "scripts/electron/plugin-v2-current-electron-fixture.mjs", + "utf8", + ); + const gate = fs.readFileSync( + "scripts/electron/mcp-elicitation-gate-b.mjs", + "utf8", + ); + const mcpAppGate = fs.readFileSync( + "scripts/electron/plugin-v2-mcp-app-gate-b.mjs", + "utf8", + ); + + expect(wrapper).toContain("run({ pluginV2: true })"); + expect(gate).toContain("ensureElectronFixtureBuild"); + expect(gate).toContain("rootDir: process.cwd()"); + expect(gate).toContain('mcpServers: "./.mcp.json"'); + expect(gate).toContain('"plugin/list"'); + expect(gate).toContain('data-testid="app-sidebar-nav-plugins"'); + expect(gate).toContain('data-testid="plugin-v2-install-local"'); + expect(gate).toContain('data-testid="plugin-v2-install-review"'); + expect(gate).toContain('data-testid="plugin-v2-confirm-install"'); + expect(gate).toContain("armControlledPluginSourceDialog"); + expect(gate).toContain("installPluginV2FromAppCenter"); + expect(gate).toContain('"plugin/install"'); + expect(gate).toContain('"plugin/read"'); + expect(gate).toContain('"plugin/installed"'); + expect(gate).toContain('"plugin/enabled/set"'); + expect(gate).toContain('data-testid="inputbar-plus-plugins"'); + expect(gate).toContain('data-testid="inputbar-plugin-option"'); + expect(gate).toContain('data-testid="inputbar-plugin-badge"'); + expect(gate).toContain('data-testid="model-selector"'); + expect(gate).toContain('data-model-selector-popover="true"'); + expect(gate).toContain("selectRuntimeRouteInRenderer"); + expect(gate).toContain("findPluginV2MentionIdentity"); + expect(gate).toContain("plugin://${PLUGIN_V2_ID}"); + expect(gate).toContain('data-testid="plugin-v2-uninstall-confirm"'); + expect(gate).toContain('"plugin/uninstall"'); + expect(gate).toContain("historyReadableAfterUninstall"); + expect(gate).toContain("mcpAppHistoryUnavailableAfterUninstall"); + expect(gate).toContain("historyMcpRuntimeNotRestartedAfterUninstall"); + expect(gate).toContain("historyProviderNotReexecutedAfterUninstall"); + expect(gate).not.toContain("async function installPluginV2("); + expect(gate).toContain("pluginV2ServerName()"); + expect(gate).toContain("managementConnectionAbsent"); + expect(gate).toContain("Plugin v2 MCP 不得通过管理面"); + expect(gate).toContain('"mcpResource/read"'); + expect(gate).toContain('method === "resources/read"'); + expect(gate).toContain('mimeType: "text/html;profile=mcp-app"'); + expect(gate).toContain("waitForPluginV2McpAppSurface"); + expect(gate).toContain("mcpAppRestoredAfterReload"); + expect(gate).toContain("cold-restart-plugin-v2-history"); + expect(gate).toContain("switchRuntimeThreadInGuiViaSidebar"); + expect(gate).toContain("waitForRendererHistoryHydrationAfterClick"); + expect(gate).toContain("waitForRendererSessionSwitchSuccessAfterClick"); + expect(gate).toContain('entry?.phase === "session.switch.success"'); + expect(gate).toContain('"thread/items/list"'); + expect(gate).toContain('"thread/turns/list"'); + expect(gate).toContain('data-testid="app-sidebar-conversation-open"'); + expect(gate).toContain("sourceSessionId: summary.disabledNewSessionId"); + expect(gate).toContain("summary.electronLaunchCount === 2"); + expect(gate).toContain("summary.coldRestoreCompleted"); + expect(gate).toContain("summary.coldRestoreIdentityStable"); + expect(gate).toContain("summary.coldRestoreExplicitSurfaceOpen"); + expect(gate).toContain("summary.coldRestoreCanonicalHydrationViaSidebar"); + expect(gate).toContain("summary.coldRestoreMcpProcessRestarted"); + expect(gate).toContain("summary.coldRestoreProviderNotReexecuted"); + expect(gate).toContain("summary.coldRestoreToolNotReexecuted"); + expect(gate).toContain("summary.mcpAppResourceReadCount >= 4"); + expect(gate).toContain( + "summary.mcpAppHtmlLoadCount === summary.mcpAppResourceReadCount", + ); + expect(gate).toContain("summary.mcpAppToolCallCount === 1"); + expect(gate).toContain("repositoryCommit: readRepositoryCommit"); + expect(gate).toContain("summary.appVersion = electronRuntime.appVersion"); + expect(gate).toContain("summary.pluginMarketplaceId"); + expect(gate).toContain("summary.pluginContentDigest"); + expect(gate).toContain("verifyPluginV2DisabledNewThreadBoundary"); + expect(gate).toContain("summary.disabledBoundaryCompleted"); + expect(gate).toContain( + "summary.disabledNewSessionId = disabledBoundary.sessionId", + ); + expect(gate).toContain("summary.disabledPluginPickerBlocked"); + expect(gate).toContain("summary.disabledProviderToolAbsent"); + expect(gate).toContain("summary.disabledThreadPluginItemsAbsent"); + expect(gate).toContain("summary.enabledNewBoundaryCapabilityRestored"); + expect(gate).toContain("summary.sessionId = runtime.sessionId"); + expect(gate).toContain("summary.threadId = runtime.threadId"); + expect(gate).toContain("summary.turnId = runtime.turnId"); + expect(gate).toContain("summary.userItemId = runtime.userItemId"); + expect(gate).toContain("summary.toolItemId = runtime.mcpAppItemId"); + expect(gate).toContain("summary.toolCallId === MCP_TOOL_CALL_ID"); + expect(gate).toContain("summary.surfaceId = firstSurface.viewId"); + expect(gate).toContain("summary.completedAt = new Date().toISOString()"); + expect(mcpAppGate).toContain("embedded_browser_view_load_html"); + expect(mcpAppGate).toContain( + "request?.params?.sessionId === runtime.sessionId", + ); + expect(mcpAppGate).toContain( + "request?.params?.threadId === runtime.threadId", + ); + expect(mcpAppGate).toContain("args_preview?.request?.lines"); + expect(mcpAppGate).toMatch(/webContents\s*\.getAllWebContents\(\)/u); + expect(mcpAppGate).not.toContain( + '.filter((entry) => entry.getType() !== "window")', + ); + expect(mcpAppGate).toContain("BrowserWindow.getAllWindows()"); + expect(mcpAppGate).toContain("browserWindowWebContentsIds.has(entry.id)"); + expect(mcpAppGate).toContain("Promise.race(["); + expect(gate).toContain('backendMode: "runtime"'); + expect(gate).not.toContain('APP_SERVER_BACKEND_MODE: "mock"'); + }); +}); diff --git a/scripts/electron/plugin-v2-mcp-app-gate-b.mjs b/scripts/electron/plugin-v2-mcp-app-gate-b.mjs new file mode 100644 index 000000000..1b8dec753 --- /dev/null +++ b/scripts/electron/plugin-v2-mcp-app-gate-b.mjs @@ -0,0 +1,475 @@ +import { + appServerCallFromPage, + assert, + parseInvokeTraceRaw, + sleep, +} from "./mcp-config-fixture-smoke.mjs"; + +export const PLUGIN_V2_MCP_APP_RESOURCE_URI = + "ui://mcp-elicitation-plugin/release-check.html"; +export const PLUGIN_V2_MCP_APP_TITLE = "Plugin v2 Release Check"; +export const PLUGIN_V2_MCP_APP_MARKER = "PLUGIN_V2_MCP_APP_GATE_B_READY"; + +export function pluginV2McpAppHtml() { + return ` + + ${PLUGIN_V2_MCP_APP_TITLE} + +

${PLUGIN_V2_MCP_APP_TITLE}

${PLUGIN_V2_MCP_APP_MARKER}

+ +`; +} + +export function findPluginV2McpAppItems(value, items = []) { + if (Array.isArray(value)) { + value.forEach((entry) => findPluginV2McpAppItems(entry, items)); + return items; + } + if (!value || typeof value !== "object") return items; + if ( + value.type === "mcpToolCall" && + value.mcpAppResourceUri === PLUGIN_V2_MCP_APP_RESOURCE_URI + ) { + items.push(value); + } + Object.values(value).forEach((entry) => + findPluginV2McpAppItems(entry, items), + ); + return items; +} + +export function pluginV2McpAppTraceEvidence(traceRaw, runtime) { + const trace = parseInvokeTraceRaw(traceRaw); + const resourceReadAttempts = trace.flatMap((entry) => { + if ( + entry?.command !== "app_server_handle_json_lines" || + entry?.transport !== "electron-ipc" + ) { + return []; + } + const request = parseJsonRpcLine(entry?.args_preview?.request?.lines); + if ( + request?.method === "mcpResource/read" && + request?.params?.sessionId === runtime.sessionId && + request?.params?.threadId === runtime.threadId && + request?.params?.uri === PLUGIN_V2_MCP_APP_RESOURCE_URI + ) { + return [ + { + error: + typeof entry?.error === "string" ? entry.error.slice(0, 500) : null, + server: request?.params?.server ?? null, + status: entry?.status ?? null, + }, + ]; + } + return []; + }); + const embeddedBrowserAttempts = trace.flatMap((entry) => { + if ( + ![ + "embedded_browser_view_load_html", + "embedded_browser_view_mount", + "embedded_browser_view_set_bounds", + "embedded_browser_view_destroy", + ].includes(entry?.command) || + entry?.transport !== "electron-ipc" + ) { + return []; + } + return [ + { + command: entry.command, + error: + typeof entry?.error === "string" ? entry.error.slice(0, 500) : null, + leaseId: entry?.args_preview?.leaseId ?? null, + source: entry?.args_preview?.source ?? null, + sourceUri: entry?.args_preview?.sourceUri ?? null, + status: entry?.status ?? null, + viewId: entry?.args_preview?.viewId ?? null, + visible: entry?.args_preview?.visible ?? null, + }, + ]; + }); + const htmlLoads = embeddedBrowserAttempts.filter( + (entry) => + entry.command === "embedded_browser_view_load_html" && + entry.status === "success" && + entry.source === "mcpApp" && + entry.sourceUri === PLUGIN_V2_MCP_APP_RESOURCE_URI, + ); + return { + embeddedBrowserAttempts, + htmlLoadCount: htmlLoads.length, + htmlLoadViewIds: htmlLoads.map((entry) => entry.viewId), + resourceReadAttempts, + resourceReadCount: resourceReadAttempts.filter( + (entry) => entry.status === "success", + ).length, + }; +} + +export async function installPluginV2EmbeddedBrowserLifecycleCapture(page) { + await page.evaluate(() => { + const stateKey = "__LIME_PLUGIN_V2_EMBEDDED_BROWSER_LIFECYCLE__"; + const listenerKey = "__LIME_PLUGIN_V2_EMBEDDED_BROWSER_UNLISTEN__"; + if (!Array.isArray(window[stateKey])) { + window[stateKey] = []; + } + if (typeof window[listenerKey] === "function") { + return; + } + window[listenerKey] = window.electronAPI?.listen?.( + "embedded-browser-view-destroyed", + (event) => { + window[stateKey].push({ + observedAt: Date.now(), + ...(event?.payload ?? {}), + }); + }, + ); + }); +} + +export async function waitForPluginV2McpAppSurface({ + app, + page, + options, + runtime, +}) { + const { containerId, viewId } = await waitForPluginV2McpAppFrame({ + page, + options, + runtime, + }); + + const startedAt = Date.now(); + let latest = null; + let latestTraceEvidence = null; + while (Date.now() - startedAt < Math.min(options.timeoutMs, 60_000)) { + latest = await readMcpAppWebContents(app); + const traceRaw = + (await page.evaluate(() => + window.localStorage.getItem("lime_invoke_trace_buffer_v1"), + )) || ""; + latestTraceEvidence = pluginV2McpAppTraceEvidence(traceRaw, runtime); + if ( + latest.some( + (entry) => + entry.title === PLUGIN_V2_MCP_APP_TITLE && + entry.marker === PLUGIN_V2_MCP_APP_MARKER, + ) + ) { + if ( + latestTraceEvidence.resourceReadCount > 0 && + latestTraceEvidence.htmlLoadCount > 0 + ) { + return { + containerId, + traceEvidence: latestTraceEvidence, + viewId, + webContents: latest, + }; + } + } + await sleep(options.intervalMs); + } + const renderer = await readMcpAppRendererDiagnostics(page, runtime); + throw new Error( + `Plugin v2 MCP App surface 未完成: ${JSON.stringify({ + renderer, + traceEvidence: latestTraceEvidence, + webContents: latest, + })}`, + ); +} + +export async function waitForPluginV2McpAppHistoryUnavailable({ + app, + page, + options, + runtime, +}) { + const { containerId, frame, viewId } = await waitForPluginV2McpAppFrame({ + page, + options, + runtime, + }); + const unavailable = page.locator( + '[data-testid="workspace-plugin-surface-history-unavailable"]', + ); + await unavailable.waitFor({ + state: "visible", + timeout: Math.min(options.timeoutMs, 30_000), + }); + assert( + (await frame.getAttribute("data-plugin-availability")) === "uninstalled" && + (await frame.getAttribute("data-mounted")) === "false", + "卸载后历史 surface 不得挂载 Plugin WebContentsView", + ); + + const traceRaw = + (await page.evaluate(() => + window.localStorage.getItem("lime_invoke_trace_buffer_v1"), + )) || ""; + const traceEvidence = pluginV2McpAppTraceEvidence(traceRaw, runtime); + const webContents = await readMcpAppWebContents(app); + assert( + !webContents.some( + (entry) => + entry.title === PLUGIN_V2_MCP_APP_TITLE || + entry.marker === PLUGIN_V2_MCP_APP_MARKER, + ), + "卸载后历史 surface 仍残留 Plugin MCP App WebContents", + ); + + return { + containerId, + message: (await unavailable.textContent())?.trim() ?? "", + traceEvidence, + viewId, + webContents, + }; +} + +async function waitForPluginV2McpAppFrame({ page, options, runtime }) { + const frame = page.locator('[data-testid="workspace-plugin-surface-frame"]'); + const frameDeadline = Date.now() + Math.min(options.timeoutMs, 30_000); + while (Date.now() < frameDeadline && !(await frame.isVisible())) { + await sleep(options.intervalMs); + } + if (!(await frame.isVisible())) { + throw new Error( + `workspace-plugin-surface-frame 未出现: ${JSON.stringify( + await readMcpAppRendererDiagnostics(page, runtime), + )}`, + ); + } + const containerId = `mcp-app-${runtime.mcpAppItemId}`; + const viewId = `plugin-surface-${containerId}`; + assert( + (await frame.getAttribute("data-view-id")) === viewId, + "Right Surface 未使用 canonical MCP item identity", + ); + return { containerId, frame, viewId }; +} + +async function readMcpAppRendererDiagnostics(page, runtime) { + const itemPage = await appServerCallFromPage(page, "thread/items/list", { + threadId: runtime.threadId, + limit: 100, + sortDirection: "desc", + }).catch((error) => ({ error: String(error), result: null })); + return await page.evaluate( + ({ itemId, itemPage, resourceUri, sessionId, threadId }) => { + const trace = JSON.parse( + window.localStorage.getItem("lime_invoke_trace_buffer_v1") || "[]", + ); + const methods = trace.flatMap((entry) => { + if (entry?.command !== "app_server_handle_json_lines") return []; + const lines = entry?.args_preview?.request?.lines; + if (!Array.isArray(lines)) return []; + return lines.flatMap((line) => { + try { + const request = JSON.parse(String(line)); + return typeof request?.method === "string" ? [request.method] : []; + } catch { + return []; + } + }); + }); + const switchSuccessEntries = ( + window.__LIME_AGENTUI_PERF__?.entries?.() ?? [] + ) + .filter( + (entry) => + entry?.phase === "session.switch.success" && + entry?.sessionId === sessionId, + ) + .slice(-5); + const testIds = Array.from(document.querySelectorAll("[data-testid]")) + .map((element) => element.getAttribute("data-testid")) + .filter( + (testId) => + typeof testId === "string" && + (testId.includes("right-surface") || + testId.includes("plugin-surface")), + ); + const storedItemMatches = [ + ["session", window.sessionStorage], + ["local", window.localStorage], + ].flatMap(([storageKind, storage]) => + Object.keys(storage).flatMap((key) => { + if ( + !key.includes("agent_thread_items") && + !key.includes("agent_session_snapshots") + ) { + return []; + } + let parsed; + try { + parsed = JSON.parse(storage.getItem(key) || "null"); + } catch { + return []; + } + const matches = []; + const visit = (value) => { + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + if (!value || typeof value !== "object") return; + if (value.id === itemId) { + matches.push({ + id: value.id, + metadata: value.metadata ?? null, + status: value.status ?? null, + threadId: value.thread_id ?? null, + type: value.type ?? null, + }); + } + Object.values(value).forEach(visit); + }; + visit(parsed); + return matches.length > 0 ? [{ key, matches, storageKind }] : []; + }), + ); + return { + activeRightSurface: + document + .querySelector('[data-testid="workspace-right-surface-host"]') + ?.getAttribute("data-surface") ?? null, + activePanePresent: Boolean( + document.querySelector( + '[data-testid="workspace-right-surface-active-pane"]', + ), + ), + appSurfaceTabPresent: Boolean( + document.querySelector( + '[data-testid="workspace-right-surface-tab-appSurface"]', + ), + ), + expectedContainerId: `mcp-app-${itemId}`, + embeddedBrowserLifecycle: Array.isArray( + window.__LIME_PLUGIN_V2_EMBEDDED_BROWSER_LIFECYCLE__, + ) + ? window.__LIME_PLUGIN_V2_EMBEDDED_BROWSER_LIFECYCLE__ + : [], + frames: Array.from( + document.querySelectorAll( + '[data-testid="workspace-plugin-surface-frame"]', + ), + ).map((element) => ({ + active: element.getAttribute("data-active"), + connected: element.isConnected, + hostAvailable: element.getAttribute("data-host-available"), + mcpResourceUri: element.getAttribute("data-mcp-resource-uri"), + mcpServer: element.getAttribute("data-mcp-server"), + mounted: element.getAttribute("data-mounted"), + runtimeSessionId: element.getAttribute("data-runtime-session-id"), + runtimeThreadId: element.getAttribute("data-runtime-thread-id"), + viewId: element.getAttribute("data-view-id"), + })), + inputSessionIds: Array.from( + document.querySelectorAll('textarea[name="agent-chat-message"]'), + ).map((element) => element.getAttribute("data-session-id")), + itemId, + itemPage: + itemPage?.result?.data?.map((entry) => ({ + id: entry?.item?.id ?? null, + mcpAppResourceUri: entry?.item?.mcpAppResourceUri ?? null, + pluginId: entry?.item?.pluginId ?? null, + server: entry?.item?.server ?? null, + status: entry?.item?.status ?? null, + turnId: entry?.turnId ?? null, + type: entry?.item?.type ?? null, + })) ?? + itemPage?.error ?? + null, + methodCounts: Object.fromEntries( + Array.from(new Set(methods)).map((method) => [ + method, + methods.filter((candidate) => candidate === method).length, + ]), + ), + resourceUri, + sessionId, + storedItemMatches, + switchSuccessEntries, + testIds, + threadId, + visibleText: document.body?.innerText?.slice(-2_000) ?? "", + }; + }, + { + itemId: runtime.mcpAppItemId, + itemPage, + resourceUri: PLUGIN_V2_MCP_APP_RESOURCE_URI, + sessionId: runtime.sessionId, + threadId: runtime.threadId, + }, + ); +} + +async function readMcpAppWebContents(app) { + return await app.evaluate( + async ({ BrowserWindow, webContents }, { marker, title }) => { + const browserWindowWebContentsIds = new Set( + BrowserWindow.getAllWindows().map((window) => window.webContents.id), + ); + return await Promise.all( + webContents + .getAllWebContents() + .filter( + (entry) => + !entry.isDestroyed() && + !browserWindowWebContentsIds.has(entry.id), + ) + .map(async (entry) => { + let observedMarker = null; + try { + observedMarker = await Promise.race([ + entry.executeJavaScript( + "document.body?.dataset?.pluginV2McpApp ?? null", + ), + new Promise((resolve) => + setTimeout(() => resolve(null), 1_000), + ), + ]); + } catch { + observedMarker = null; + } + const currentTitle = entry.getTitle(); + return { + id: entry.id, + isDestroyed: entry.isDestroyed(), + isLoading: entry.isLoading(), + marker: observedMarker === marker ? observedMarker : null, + title: currentTitle.slice(0, 160) || null, + titleMatches: currentTitle === title, + type: entry.getType(), + urlScheme: entry.getURL().split(":", 1)[0] || null, + }; + }), + ); + }, + { marker: PLUGIN_V2_MCP_APP_MARKER, title: PLUGIN_V2_MCP_APP_TITLE }, + ); +} + +function parseJsonRpcLine(lines) { + const serialized = Array.isArray(lines) + ? lines.filter((entry) => typeof entry === "string").join("\n") + : lines; + if (typeof serialized !== "string") return null; + const line = serialized + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .find(Boolean); + if (!line) return null; + try { + return JSON.parse(line); + } catch { + return null; + } +} diff --git a/scripts/electron/renderer-build-env.mjs b/scripts/electron/renderer-build-env.mjs index 9d102a778..9fe74e2d6 100644 --- a/scripts/electron/renderer-build-env.mjs +++ b/scripts/electron/renderer-build-env.mjs @@ -1,5 +1,15 @@ +import { mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; + const RENDERER_BUILD_NODE_OPTIONS = "--max-old-space-size=8192"; const DEFAULT_HEARTBEAT_INTERVAL_MS = 60_000; +const RENDERER_BUILD_LOCK_PATH = path.join( + ".lime", + "electron-renderer-build.lock", +); +const RENDERER_BUILD_LOCK_WAIT_MS = 1_000; +const RENDERER_BUILD_LOCK_TIMEOUT_MS = 20 * 60_000; +const RENDERER_BUILD_LOCK_STALE_MS = 30 * 60_000; export function rendererBuildEnv(env = process.env) { const nodeOptions = env.NODE_OPTIONS ?? ""; @@ -14,6 +24,65 @@ export function rendererBuildEnv(env = process.env) { }; } +export function acquireRendererBuildLock({ + rootDir = process.cwd(), + logPrefix = "electron-renderer-build", +} = {}) { + const lockPath = path.join(rootDir, RENDERER_BUILD_LOCK_PATH); + const startedAt = Date.now(); + let loggedWait = false; + + while (Date.now() - startedAt < RENDERER_BUILD_LOCK_TIMEOUT_MS) { + mkdirSync(path.dirname(lockPath), { recursive: true }); + try { + mkdirSync(lockPath); + writeFileSync( + path.join(lockPath, "owner.json"), + `${JSON.stringify( + { pid: process.pid, createdAt: new Date().toISOString() }, + null, + 2, + )}\n`, + ); + return () => rmSync(lockPath, { force: true, recursive: true }); + } catch (error) { + if (error?.code !== "EEXIST") { + throw error; + } + } + + let lockStats = null; + try { + lockStats = statSync(lockPath); + } catch { + continue; + } + if (Date.now() - lockStats.mtimeMs > RENDERER_BUILD_LOCK_STALE_MS) { + console.warn( + `[${logPrefix}] removing stale renderer build lock after ${Math.round( + (Date.now() - lockStats.mtimeMs) / 1000, + )}s: ${path.relative(rootDir, lockPath)}`, + ); + rmSync(lockPath, { force: true, recursive: true }); + continue; + } + + if (!loggedWait) { + console.log(`[${logPrefix}] waiting for renderer build lock`); + loggedWait = true; + } + const buffer = new SharedArrayBuffer(4); + Atomics.wait(new Int32Array(buffer), 0, 0, RENDERER_BUILD_LOCK_WAIT_MS); + } + + throw new Error( + `[${logPrefix}] timed out waiting for renderer build lock: ${path.relative( + rootDir, + lockPath, + )}`, + ); +} + export function startRendererBuildHeartbeat({ intervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS, now = () => new Date(), diff --git a/scripts/lib/electron-fixture-build.mjs b/scripts/lib/electron-fixture-build.mjs index c2b8d038e..630d72e78 100644 --- a/scripts/lib/electron-fixture-build.mjs +++ b/scripts/lib/electron-fixture-build.mjs @@ -36,7 +36,11 @@ const RENDERER_SOURCE_FILES = [ "scripts/electron/renderer-build-env.mjs", "scripts/generate-extension-site-adapter-runners.mjs", ]; -const HOST_SOURCE_DIRS = ["electron", "packages/app-server-client/src"]; +const HOST_SOURCE_DIRS = [ + "electron", + "packages/app-server-client/src", + "assets/plugins", +]; const HOST_SOURCE_FILES = [ "package-lock.json", "package.json", @@ -143,6 +147,15 @@ function electronFixtureBuildSegments({ rootDir }) { requiredFiles: [ path.join(rootDir, "dist-electron", "main", "main.js"), path.join(rootDir, "dist-electron", "preload", "preload.cjs"), + path.join( + rootDir, + "dist-electron", + "plugins", + "openai-bundled", + ".agents", + "plugins", + "marketplace.json", + ), ], sourceDirs: HOST_SOURCE_DIRS, sourceFiles: HOST_SOURCE_FILES, diff --git a/src/components/AppPageContent.test.tsx b/src/components/AppPageContent.test.tsx index e294621bd..580836605 100644 --- a/src/components/AppPageContent.test.tsx +++ b/src/components/AppPageContent.test.tsx @@ -88,8 +88,8 @@ vi.mock("@/features/plugin/ui/PluginRuntimePage", () => ({ }, })); -vi.mock("@/features/plugin/ui/PluginsPage", () => ({ - PluginsPage: (props: Record) => { +vi.mock("@/features/plugin/ui/PluginCatalogPage", () => ({ + PluginCatalogPage: (props: Record) => { latestPluginsProps.value = props; useEffect(() => { pluginsLifecycle.mounts += 1; @@ -313,9 +313,7 @@ describe("AppPageContent", () => { expect(typeof onSessionChange).toBe("function"); await act(async () => { - (onSessionChange as (sessionId: string | null) => void)( - " session-live ", - ); + (onSessionChange as (sessionId: string | null) => void)(" session-live "); await Promise.resolve(); }); @@ -1067,9 +1065,7 @@ describe("AppPageContent", () => { selectedPluginId: "content-factory-app", }, }); - expect(latestPluginsProps.value?.onNavigate).toEqual( - expect.any(Function), - ); + expect(latestPluginsProps.value?.onNavigate).toEqual(expect.any(Function)); }); it("plugin 页面应渲染已安装 App 的独立使用入口", async () => { diff --git a/src/components/AppPageContent.tsx b/src/components/AppPageContent.tsx index 977c935a6..6c8dbd8b4 100644 --- a/src/components/AppPageContent.tsx +++ b/src/components/AppPageContent.tsx @@ -57,8 +57,8 @@ const loadPluginLabPage = () => default: module.PluginLabPage, })); const loadPluginsPage = () => - import("@/features/plugin/ui/PluginsPage").then((module) => ({ - default: module.PluginsPage, + import("@/features/plugin/ui/PluginCatalogPage").then((module) => ({ + default: module.PluginCatalogPage, })); const loadPluginRuntimePage = () => @@ -284,9 +284,7 @@ export function AppPageContent({ if (activePage === "plugin") { return (
- +
); } diff --git a/src/components/agent/chat/AgentChatWorkspace.workspaceBoundaryGuard.test.ts b/src/components/agent/chat/AgentChatWorkspace.workspaceBoundaryGuard.test.ts index de70760d6..f7de7b296 100644 --- a/src/components/agent/chat/AgentChatWorkspace.workspaceBoundaryGuard.test.ts +++ b/src/components/agent/chat/AgentChatWorkspace.workspaceBoundaryGuard.test.ts @@ -568,7 +568,7 @@ describe("AgentChatWorkspace expert skill panel runtime boundary", () => { "useWorkspaceExpertAgentLaunchSyncRuntime({", "resolveWorkspaceRequestMetadataWithExpertSkills({", "useWorkspacePluginRuntimeContext({", - "buildWorkspacePluginInputSuggestions(", + "useWorkspacePluginCatalogSuggestions({", ]) { expect(workspaceSource).not.toContain(retiredExpertSkillPanelOwner); expect(ownerSource).toContain(retiredExpertSkillPanelOwner); diff --git a/src/components/agent/chat/components/FileManager/FileManagerSidebar.test.tsx b/src/components/agent/chat/components/FileManager/FileManagerSidebar.test.tsx index b92f50b1a..f16acd1c1 100644 --- a/src/components/agent/chat/components/FileManager/FileManagerSidebar.test.tsx +++ b/src/components/agent/chat/components/FileManager/FileManagerSidebar.test.tsx @@ -99,6 +99,21 @@ function createListing(path: string): DirectoryListing { path, parentPath: path === "/Users/demo" ? null : "/Users/demo", entries: [ + { + name: ".config", + path: "/Users/demo/.config", + isDir: true, + size: 0, + modifiedAt: Date.now(), + }, + { + name: ".env", + path: "/Users/demo/.env", + isDir: false, + size: 64, + modifiedAt: Date.now(), + mimeType: "text/plain", + }, { name: "Downloads", path: "/Users/demo/Downloads", @@ -241,6 +256,37 @@ afterEach(() => { }); describe("FileManagerSidebar", () => { + it("默认隐藏点开头目录,并可通过眼睛按钮切换显示", async () => { + const { container } = await renderFileManagerSidebar(); + const toggle = container.querySelector( + '[data-testid="file-manager-toggle-hidden-directories"]', + ) as HTMLButtonElement | null; + + expect(toggle?.getAttribute("aria-label")).toBe("显示隐藏目录"); + expect(toggle?.getAttribute("aria-pressed")).toBe("false"); + expect(container.textContent).not.toContain(".config"); + expect(container.textContent).toContain(".env"); + expect(container.textContent).toContain("Downloads"); + expect(container.textContent).toContain("brief.txt"); + + await act(async () => { + toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(toggle?.getAttribute("aria-label")).toBe("隐藏隐藏目录"); + expect(toggle?.getAttribute("aria-pressed")).toBe("true"); + expect(container.textContent).toContain(".config"); + + await act(async () => { + toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(toggle?.getAttribute("aria-pressed")).toBe("false"); + expect(container.textContent).not.toContain(".config"); + }); + it("图标读取很慢时也应先完成目录加载", async () => { vi.mocked(getFileIconDataUrl).mockImplementation( () => new Promise(() => undefined), diff --git a/src/components/agent/chat/components/FileManager/FileManagerSidebar.tsx b/src/components/agent/chat/components/FileManager/FileManagerSidebar.tsx index 9a3bcb637..aa3141bb4 100644 --- a/src/components/agent/chat/components/FileManager/FileManagerSidebar.tsx +++ b/src/components/agent/chat/components/FileManager/FileManagerSidebar.tsx @@ -12,6 +12,8 @@ import { Copy, Download, ExternalLink, + Eye, + EyeOff, FileText, Folder, Home, @@ -204,6 +206,7 @@ export const FileManagerSidebar: React.FC = ({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [viewMode, setViewMode] = useState("list"); + const [showHiddenDirectories, setShowHiddenDirectories] = useState(false); const [contextMenu, setContextMenu] = useState(null); const iconDataUrlCacheRef = useRef>(new Map()); const entriesRef = useRef([]); @@ -440,12 +443,22 @@ export const FileManagerSidebar: React.FC = ({ ); }, [activePath, allLocations, t]); + const visibleEntries = useMemo( + () => + showHiddenDirectories + ? entries + : entries.filter( + (entry) => !entry.isDir || !entry.name.startsWith("."), + ), + [entries, showHiddenDirectories], + ); + const sortedEntries = useMemo( () => - entries + visibleEntries .slice() .sort((left, right) => compareFileManagerEntries(left, right, locale)), - [entries, locale], + [locale, visibleEntries], ); const handleSelectLocation = useCallback((location: FileManagerLocation) => { @@ -877,6 +890,32 @@ export const FileManagerSidebar: React.FC = ({ +
= ({ ) : null}
- {!loading && entries.length === 0 ? ( + {!loading && sortedEntries.length === 0 ? (
{t("agentChat.fileManager.emptyDirectory")} diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.test.tsx b/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.test.tsx index e0110ab78..4d590f606 100644 --- a/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.test.tsx +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.test.tsx @@ -232,11 +232,18 @@ describe("useInputbarSend", () => { await send(); - expect(onSend).toHaveBeenCalledWith({ - images: undefined, - textOverride: "@写文章 帮我整理项目资料", - sendOptions: undefined, - }); + const payload = onSend.mock.calls[0]?.[0]; + expect(payload?.textOverride).toBe("@写文章 帮我整理项目资料"); + expect(payload?.sendOptions?.inputMentions).toEqual([ + { + type: "mention", + name: "写文章", + path: "plugin://content-factory-app", + }, + ]); + expect(payload?.sendOptions?.inputRestoreDraft?.textElements).toEqual([ + { type: "text", text: "@写文章 帮我整理项目资料" }, + ]); }); it("只有路径引用时仍应发送路径占位文本并保留 metadata", async () => { diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts b/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts index c894ea3a6..643b23bba 100644 --- a/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts @@ -3,6 +3,7 @@ import type { MessageImage, MessagePathReference } from "../../../types"; import type { InputbarKnowledgePackSelection } from "../types"; import type { InputbarSendHandler } from "../inputbarSendPayload"; import { + buildInputbarPluginMention, resolveInputbarPluginSubmissionText, type InputbarPluginSelection, } from "../pluginInputCapability"; @@ -75,6 +76,9 @@ export function useInputbarSend({ input, selection: activePluginSelection, }); + const inputMentions = activePluginSelection + ? [buildInputbarPluginMention(activePluginSelection.plugin)] + : []; if ( !submittedInput.trim() && pendingImages.length === 0 && @@ -186,12 +190,14 @@ export function useInputbarSend({ requestMetadata || threadGoal || toolPreferencesOverride || - collaborationMode + collaborationMode || + inputMentions.length > 0 ? { ...(capabilityDispatch.capabilityRoute ? { capabilityRoute: capabilityDispatch.capabilityRoute } : {}), inputRestoreDraft, + ...(inputMentions.length > 0 ? { inputMentions } : {}), ...(capabilityDispatch.displayContent || submittedInput.trim() ? { displayContent: diff --git a/src/components/agent/chat/components/Inputbar/pluginInputCapability.ts b/src/components/agent/chat/components/Inputbar/pluginInputCapability.ts index 49367b168..016a82315 100644 --- a/src/components/agent/chat/components/Inputbar/pluginInputCapability.ts +++ b/src/components/agent/chat/components/Inputbar/pluginInputCapability.ts @@ -32,14 +32,39 @@ export interface InputbarPluginSelectionOptions { preserveInputOverride?: boolean; } +export interface InputbarPluginMention { + type: "mention"; + name: string; + path: string; +} + export function resolveInputbarPluginDisplayName( plugin: Pick, ): string { return plugin.displayName.trim() || plugin.pluginId.trim(); } +export function buildInputbarPluginMention( + plugin: Pick, +): InputbarPluginMention { + const pluginId = plugin.pluginId.trim(); + if (!pluginId) { + throw new Error( + "pluginId is required to build a structured Plugin mention", + ); + } + return { + type: "mention", + name: resolveInputbarPluginDisplayName(plugin), + path: `plugin://${pluginId}`, + }; +} + export function normalizeInputbarPluginTrigger( - plugin: Pick, + plugin: Pick< + InputbarPluginCapability, + "displayName" | "pluginId" | "trigger" + >, skill?: Pick, ): string { const explicitSkillTrigger = skill?.trigger?.trim(); @@ -50,7 +75,9 @@ export function normalizeInputbarPluginTrigger( } const explicitTrigger = plugin.trigger?.trim(); if (explicitTrigger && !skill) { - return explicitTrigger.startsWith("@") ? explicitTrigger : `@${explicitTrigger}`; + return explicitTrigger.startsWith("@") + ? explicitTrigger + : `@${explicitTrigger}`; } const displayName = resolveInputbarPluginDisplayName(plugin); const pluginTrigger = displayName ? `@${displayName}` : "@"; diff --git a/src/components/agent/chat/components/Inputbar/pluginInputCapability.unit.test.ts b/src/components/agent/chat/components/Inputbar/pluginInputCapability.unit.test.ts index e45c5760c..82e03bbb5 100644 --- a/src/components/agent/chat/components/Inputbar/pluginInputCapability.unit.test.ts +++ b/src/components/agent/chat/components/Inputbar/pluginInputCapability.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { applyInputbarPluginSelection, + buildInputbarPluginMention, isCompleteInputbarPluginTriggerQuery, normalizeInputbarPluginTrigger, removeInputbarPluginSelection, @@ -9,6 +10,19 @@ import { } from "./pluginInputCapability"; describe("pluginInputCapability", () => { + it("应从稳定 Plugin ID 构造结构化 mention", () => { + expect( + buildInputbarPluginMention({ + pluginId: "browser", + displayName: "Browser", + }), + ).toEqual({ + type: "mention", + name: "Browser", + path: "plugin://browser", + }); + }); + it("展示名称为空时应回退显示插件 id", () => { expect( resolveInputbarPluginDisplayName({ diff --git a/src/components/agent/chat/hooks/agentChatShared.ts b/src/components/agent/chat/hooks/agentChatShared.ts index e440be9b0..4f3c1c62c 100644 --- a/src/components/agent/chat/hooks/agentChatShared.ts +++ b/src/components/agent/chat/hooks/agentChatShared.ts @@ -3,7 +3,11 @@ import type { AgentSessionInfo, AutoContinueRequestPayload, } from "@/lib/api/agentRuntime/sessionTypes"; -import type { ModeKind, RuntimeSearchMode } from "@limecloud/app-server-client"; +import type { + ModeKind, + RuntimeSearchMode, + UserInput, +} from "@limecloud/app-server-client"; import type { AgentRuntimeStatus, Message, @@ -112,6 +116,8 @@ export interface ThreadGoalInput { tokenBudget?: number | null; } +export type AgentInputMention = Extract; + export interface SendMessageOptions { purpose?: Message["purpose"]; observer?: SendMessageObserver; @@ -120,6 +126,7 @@ export interface SendMessageOptions { threadGoal?: ThreadGoalInput; assistantDraft?: AssistantDraftState; inputRestoreDraft?: InterruptedInputDraftSnapshot; + inputMentions?: readonly AgentInputMention[]; displayContent?: string; capabilityRoute?: InputCapabilitySendRoute; skillRequest?: SlashSkillRequest; diff --git a/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts b/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts index 8037a2060..198e74694 100644 --- a/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts +++ b/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts @@ -24,6 +24,7 @@ import { import { MODEL_SELECTION_REQUIRED_ERROR_MESSAGE } from "../utils/agentRuntimeErrorPresentation"; import type { AssistantDraftState, + AgentInputMention, SendMessageObserver, SessionModelPreference, ThreadGoalInput, @@ -72,6 +73,7 @@ interface ExecuteAgentStreamSubmitOptions { effectiveAccessMode: AgentAccessMode; content: string; images: MessageImage[]; + inputMentions?: readonly AgentInputMention[]; skipUserMessage: boolean; effectiveProviderType: string; effectiveModel: string; @@ -213,6 +215,7 @@ export async function executeAgentStreamSubmit( effectiveAccessMode, content, images, + inputMentions, skipUserMessage, effectiveProviderType, effectiveModel, @@ -312,7 +315,12 @@ export async function executeAgentStreamSubmit( const params: TurnSteerParams = { threadId: resolvedThreadId, expectedTurnId, - input: buildTurnInput({ content, images, modelCapabilitySummary }), + input: buildTurnInput({ + content, + images, + inputMentions, + modelCapabilitySummary, + }), ...(clientUserMessageId?.trim() ? { clientUserMessageId: clientUserMessageId.trim() } : {}), @@ -464,6 +472,7 @@ export async function executeAgentStreamSubmit( const submitOp = buildAgentStreamSubmitOp({ content, images, + inputMentions, activeThreadId: resolvedThreadId, clientUserMessageId, eventName, diff --git a/src/components/agent/chat/hooks/agentStreamUserInputSendPreparation.test.ts b/src/components/agent/chat/hooks/agentStreamUserInputSendPreparation.test.ts index 0e9941181..66f75ab99 100644 --- a/src/components/agent/chat/hooks/agentStreamUserInputSendPreparation.test.ts +++ b/src/components/agent/chat/hooks/agentStreamUserInputSendPreparation.test.ts @@ -138,6 +138,13 @@ describe("agentStreamUserInputSendPreparation", () => { options: { requestMetadata: { source: "test" }, threadGoal: { objective: "完成 Codex 主链", tokenBudget: 50_000 }, + inputMentions: [ + { + type: "mention", + name: "Browser", + path: "plugin://browser", + }, + ], }, env, }); @@ -154,6 +161,13 @@ describe("agentStreamUserInputSendPreparation", () => { objective: "完成 Codex 主链", tokenBudget: 50_000, }); + expect(result.inputMentions).toEqual([ + { + type: "mention", + name: "Browser", + path: "plugin://browser", + }, + ]); expect(result.assistantMsgId).toBe("00000000-0000-0000-0000-000000000001"); expect(result.userMsgId).toBe("00000000-0000-0000-0000-000000000002"); expect(messages).toHaveLength(2); diff --git a/src/components/agent/chat/hooks/agentStreamUserInputSendPreparation.ts b/src/components/agent/chat/hooks/agentStreamUserInputSendPreparation.ts index 03f8d3f96..2d52b8cf7 100644 --- a/src/components/agent/chat/hooks/agentStreamUserInputSendPreparation.ts +++ b/src/components/agent/chat/hooks/agentStreamUserInputSendPreparation.ts @@ -3,6 +3,7 @@ import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime/sessionT import type { ModeKind } from "@limecloud/app-server-client"; import type { AssistantDraftState, + AgentInputMention, SendMessageObserver, SendMessageOptions, SessionModelPreference, @@ -80,6 +81,7 @@ export interface PreparedAgentStreamUserInputSend { userMsg: Message | null; assistantMsg: Message; submittedDraft?: InterruptedInputDraftSnapshot | null; + inputMentions?: AgentInputMention[]; } function shouldProjectModelInputCapabilityGate( @@ -365,6 +367,9 @@ export function prepareAgentStreamUserInputSend( submittedDraft, ); const skillRequest = sendOptions?.skillRequest; + const inputMentions = (sendOptions?.inputMentions ?? []).map((mention) => ({ + ...mention, + })); const { assistantMsg, userMsg } = prepareAgentStreamSubmitDraft({ content, displayContent, @@ -413,5 +418,6 @@ export function prepareAgentStreamUserInputSend( userMsg, assistantMsg, submittedDraft, + inputMentions, }; } diff --git a/src/components/agent/chat/hooks/agentStreamUserInputSubmission.ts b/src/components/agent/chat/hooks/agentStreamUserInputSubmission.ts index 7627630c7..e28f06c25 100644 --- a/src/components/agent/chat/hooks/agentStreamUserInputSubmission.ts +++ b/src/components/agent/chat/hooks/agentStreamUserInputSubmission.ts @@ -36,6 +36,7 @@ export async function submitAgentStreamUserInput( assistantDraft, targetSessionId, submittedDraft, + inputMentions, skipSessionRestore, skipSessionStartHooks, observer, @@ -89,6 +90,7 @@ export async function submitAgentStreamUserInput( effectiveAccessMode: env.accessMode, content, images, + inputMentions, skipUserMessage, effectiveProviderType, effectiveModel, diff --git a/src/components/agent/chat/hooks/handleSendTypes.ts b/src/components/agent/chat/hooks/handleSendTypes.ts index 680762a4f..d9263216b 100644 --- a/src/components/agent/chat/hooks/handleSendTypes.ts +++ b/src/components/agent/chat/hooks/handleSendTypes.ts @@ -1,4 +1,5 @@ import type { + AgentInputMention, AssistantDraftState, SlashSkillRequest, ThreadGoalInput, @@ -27,6 +28,7 @@ export interface HandleSendOptions { skillRequest?: SlashSkillRequest; capabilityRoute?: InputCapabilitySendRoute; inputRestoreDraft?: InterruptedInputDraftSnapshot; + inputMentions?: readonly AgentInputMention[]; providerOverride?: string; modelOverride?: string; reasoningEffort?: string; diff --git a/src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts b/src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts index a7ca12977..2e61c482a 100644 --- a/src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts +++ b/src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts @@ -12,6 +12,8 @@ import { shouldApplyPendingSessionShell, shouldLoadCachedTopicSnapshot, shouldRefreshCachedSnapshotImmediately, + shouldReuseHydratedCurrentSession, + shouldReusePendingCurrentSessionHydration, shouldReuseActiveSessionSwitch, } from "./sessionSwitchSnapshotController"; import type { AgentSessionCachedSnapshot } from "./agentSessionScopedStorage"; @@ -49,6 +51,59 @@ function snapshot( } describe("sessionSwitchSnapshotController", () => { + it("仅 canonical detail 已应用时才复用当前会话", () => { + expect( + shouldReuseHydratedCurrentSession({ + currentSessionId: "topic-a", + hydratedSessionId: null, + messagesCount: 2, + topicId: "topic-a", + }), + ).toBe(false); + expect( + shouldReuseHydratedCurrentSession({ + currentSessionId: "topic-a", + hydratedSessionId: "topic-a", + messagesCount: 2, + topicId: "topic-a", + }), + ).toBe(true); + expect( + shouldReuseHydratedCurrentSession({ + currentSessionId: "topic-a", + forceRefresh: true, + hydratedSessionId: "topic-a", + messagesCount: 2, + topicId: "topic-a", + }), + ).toBe(false); + }); + + it("当前会话已在后台水合时应复用 pending 请求", () => { + expect( + shouldReusePendingCurrentSessionHydration({ + currentSessionId: "topic-a", + pendingSessionId: "topic-a", + topicId: "topic-a", + }), + ).toBe(true); + expect( + shouldReusePendingCurrentSessionHydration({ + currentSessionId: "topic-a", + forceRefresh: true, + pendingSessionId: "topic-a", + topicId: "topic-a", + }), + ).toBe(false); + expect( + shouldReusePendingCurrentSessionHydration({ + currentSessionId: "topic-a", + pendingSessionId: "topic-b", + topicId: "topic-a", + }), + ).toBe(false); + }); + it("当前会话不应重复加载或应用 cached snapshot", () => { expect( shouldLoadCachedTopicSnapshot({ diff --git a/src/components/agent/chat/hooks/sessionSwitchSnapshotController.ts b/src/components/agent/chat/hooks/sessionSwitchSnapshotController.ts index e76617ef9..dfb95948e 100644 --- a/src/components/agent/chat/hooks/sessionSwitchSnapshotController.ts +++ b/src/components/agent/chat/hooks/sessionSwitchSnapshotController.ts @@ -38,6 +38,34 @@ export interface SessionSwitchPendingShellPlan { shouldSetSessionHydrating: boolean; } +export function shouldReuseHydratedCurrentSession(params: { + currentSessionId?: string | null; + forceRefresh?: boolean; + hydratedSessionId?: string | null; + messagesCount: number; + topicId: string; +}): boolean { + return Boolean( + params.forceRefresh !== true && + params.currentSessionId === params.topicId && + params.hydratedSessionId === params.topicId && + params.messagesCount > 0, + ); +} + +export function shouldReusePendingCurrentSessionHydration(params: { + currentSessionId?: string | null; + forceRefresh?: boolean; + pendingSessionId?: string | null; + topicId: string; +}): boolean { + return Boolean( + params.forceRefresh !== true && + params.currentSessionId === params.topicId && + params.pendingSessionId === params.topicId, + ); +} + export function shouldLoadCachedTopicSnapshot(params: { currentSessionId?: string | null; topicId: string; diff --git a/src/components/agent/chat/hooks/useAgentChat.test/preferencesSnapshotHydration.case.tsx b/src/components/agent/chat/hooks/useAgentChat.test/preferencesSnapshotHydration.case.tsx index 2d74201fa..7fd6d6f26 100644 --- a/src/components/agent/chat/hooks/useAgentChat.test/preferencesSnapshotHydration.case.tsx +++ b/src/components/agent/chat/hooks/useAgentChat.test/preferencesSnapshotHydration.case.tsx @@ -12,6 +12,96 @@ import { } from "../useAgentChat.testUtils"; describe("useAgentChat 偏好持久化 - snapshot hydration", () => { + it("冷恢复已有消息缓存时仍应合并新增 canonical MCP item", async () => { + const workspaceId = "ws-cold-restore-mcp-item"; + const sessionId = "session-cold-restore-mcp-item"; + const threadId = "thread-cold-restore-mcp-item"; + const turnId = "turn-cold-restore-mcp-item"; + const topicsDeferred = createDeferred(); + + seedSession(workspaceId, sessionId); + mockListAgentRuntimeSessions.mockReturnValue(topicsDeferred.promise); + mockGetAgentRuntimeSession.mockResolvedValue({ + id: sessionId, + thread_id: threadId, + workspace_id: workspaceId, + messages: [], + turns: [ + { + id: turnId, + thread_id: threadId, + status: "completed", + prompt_text: "运行插件检查", + started_at: "2026-08-04T00:00:00.000Z", + completed_at: "2026-08-04T00:00:02.000Z", + created_at: "2026-08-04T00:00:00.000Z", + updated_at: "2026-08-04T00:00:02.000Z", + }, + ], + items: [ + { + id: "item-mcp-app-cold-restore", + thread_id: threadId, + turn_id: turnId, + sequence: 1, + type: "tool_call", + tool_name: "mcp__plugin__demo__release_check", + status: "completed", + success: true, + started_at: "2026-08-04T00:00:01.000Z", + completed_at: "2026-08-04T00:00:02.000Z", + updated_at: "2026-08-04T00:00:02.000Z", + metadata: { + canonical_type: "mcpToolCall", + plugin_id: "demo-plugin", + server: "mcp__plugin__demo", + mcp_app_resource_uri: "ui://demo/release-check.html", + }, + }, + ], + thread_read: { + thread_id: threadId, + status: "idle", + }, + execution_strategy: "react", + }); + + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + expect(harness.getValue().sessionId).toBe(sessionId); + expect(harness.getValue().messages).toHaveLength(1); + + await act(async () => { + await harness.getValue().switchTopic(sessionId); + }); + await flushEffects(); + + expect(mockGetAgentRuntimeSession).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ historyLimit: 40 }), + ); + expect(harness.getValue().threadItems).toContainEqual( + expect.objectContaining({ + id: "item-mcp-app-cold-restore", + metadata: expect.objectContaining({ + canonical_type: "mcpToolCall", + mcp_app_resource_uri: "ui://demo/release-check.html", + }), + }), + ); + + mockGetAgentRuntimeSession.mockClear(); + await act(async () => { + await harness.getValue().switchTopic(sessionId); + }); + expect(mockGetAgentRuntimeSession).not.toHaveBeenCalled(); + } finally { + harness.unmount(); + } + }); + it("直接会话水合失败时应向导航层返回错误结果", async () => { const workspaceId = "ws-topic-hydration-error"; const hydrationError = new Error( diff --git a/src/components/agent/chat/hooks/useAgentSession.ts b/src/components/agent/chat/hooks/useAgentSession.ts index 501afd52a..265b8263b 100644 --- a/src/components/agent/chat/hooks/useAgentSession.ts +++ b/src/components/agent/chat/hooks/useAgentSession.ts @@ -86,6 +86,8 @@ import { buildSessionSwitchStartStatePlan, buildSessionSwitchStartMetricContext, shouldApplyPendingSessionShell, + shouldReuseHydratedCurrentSession, + shouldReusePendingCurrentSessionHydration, shouldReuseActiveSessionSwitch, } from "./sessionSwitchSnapshotController"; import { @@ -495,6 +497,10 @@ export function useAgentSession(options: UseAgentSessionOptions) { promise: Promise<"success" | "error">; } | null>(null); const deferredSessionHydrationCancelRef = useRef<(() => void) | null>(null); + const pendingSessionDetailHydrationRef = useRef<{ + requestVersion: number; + topicId: string; + } | null>(null); const pendingSessionMetadataSyncCancelRef = useRef<(() => void) | null>(null); const createFreshSessionPromiseRef = useRef | null>( null, @@ -629,6 +635,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { const invalidatePendingSessionSwitches = useCallback(() => { deferredSessionHydrationCancelRef.current?.(); deferredSessionHydrationCancelRef.current = null; + pendingSessionDetailHydrationRef.current = null; pendingSessionMetadataSyncCancelRef.current?.(); pendingSessionMetadataSyncCancelRef.current = null; sessionSwitchRequestVersionRef.current += 1; @@ -1498,7 +1505,6 @@ export function useAgentSession(options: UseAgentSessionOptions) { setRecoveredStreamBindingSessionId(null); resetPendingActions(); resetStreamingRefs(); - hydratedSessionRef.current = resolvedSessionId; restoredWorkspaceRef.current = resolvedWorkspaceId || null; persistSessionRestoreCandidate(resolvedSessionId); @@ -1657,7 +1663,6 @@ export function useAgentSession(options: UseAgentSessionOptions) { selectedTopic, topicId, }); - hydratedSessionRef.current = topicId; applySessionSnapshot({ ...createEmptyAgentSessionSnapshot(), sessionId: cachedTopicViewModel.sessionId, @@ -1878,7 +1883,6 @@ export function useAgentSession(options: UseAgentSessionOptions) { const shadowAccessMode = loadSessionAccessMode(topicId); persistSessionRestoreCandidate(topicId); - hydratedSessionRef.current = topicId; const applyFinalizeSuccessState = () => { const finalizeSuccessStatePlan = buildSessionFinalizeSuccessStatePlan(); if (finalizeSuccessStatePlan.shouldClearAutoRestoringSession) { @@ -1898,6 +1902,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { shadowExecutionStrategyFallback, }), }); + hydratedSessionRef.current = topicId; applyFinalizeSuccessState(); }; @@ -2150,9 +2155,24 @@ export function useAgentSession(options: UseAgentSessionOptions) { }, ) => { if ( - !options?.forceRefresh && - topicId === sessionIdRef.current && - messages.length > 0 + shouldReuseHydratedCurrentSession({ + currentSessionId: sessionIdRef.current, + forceRefresh: options?.forceRefresh, + hydratedSessionId: hydratedSessionRef.current, + messagesCount: messages.length, + topicId, + }) + ) { + return; + } + if ( + shouldReusePendingCurrentSessionHydration({ + currentSessionId: sessionIdRef.current, + forceRefresh: options?.forceRefresh, + pendingSessionId: + pendingSessionDetailHydrationRef.current?.topicId ?? null, + topicId, + }) ) { return; } @@ -2311,9 +2331,20 @@ export function useAgentSession(options: UseAgentSessionOptions) { deferHydrationMetricContext, ); let deferredHydrationRetryCount = 0; + const pendingHydration = { + requestVersion: switchRequestVersion, + topicId, + }; + pendingSessionDetailHydrationRef.current = pendingHydration; + const clearPendingHydration = () => { + if (pendingSessionDetailHydrationRef.current === pendingHydration) { + pendingSessionDetailHydrationRef.current = null; + } + }; const hydrateCachedTopic = () => { deferredSessionHydrationCancelRef.current = null; void (async () => { + let keepPendingHydration = false; try { const detail = await loadRuntimeSessionDetail({ topicId, @@ -2356,6 +2387,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { workspaceId, }); if (retryAction.kind === "retry") { + keepPendingHydration = true; deferredHydrationRetryCount = retryAction.nextRetryCount; recordAgentUiPerformanceMetric( retryAction.metricName, @@ -2390,6 +2422,10 @@ export function useAgentSession(options: UseAgentSessionOptions) { handleSwitchTopicError(retryAction.error, topicId, { preserveCurrentSnapshot: true, }); + } finally { + if (!keepPendingHydration) { + clearPendingHydration(); + } } })(); }; @@ -2408,7 +2444,6 @@ export function useAgentSession(options: UseAgentSessionOptions) { const pendingShellPlan = buildSessionSwitchPendingShellPlan({ topicId, }); - hydratedSessionRef.current = pendingShellPlan.sessionId; if (pendingShellPlan.shouldApplyEmptySessionSnapshot) { applySessionSnapshot({ ...createEmptyAgentSessionSnapshot(), @@ -3100,6 +3135,9 @@ export function useAgentSession(options: UseAgentSessionOptions) { if (activeSessionSwitchRef.current?.topicId === sessionId) { return; } + if (pendingSessionDetailHydrationRef.current?.topicId === sessionId) { + return; + } const restoreCandidateSessionId = restoreCandidateSessionIdRef.current?.trim() || null; @@ -3248,7 +3286,6 @@ export function useAgentSession(options: UseAgentSessionOptions) { return; } - hydratedSessionRef.current = sessionId; const shouldResumeHydrationSession = shouldResumeTaskSession(selectedTopic); logAgentDebug("useAgentSession", "hydrateSession.start", { cacheMode: hasLocalTimelineCache diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 52cb749a3..571b2a62e 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -447,6 +447,8 @@ describe("AgentChatPage 任务中心顶部工具区", () => { const { container } = mounted; await flushEffects(); + expect(switchTopic).not.toHaveBeenCalled(); + expect( requestTaskCenterDraftTask({ source: "sidebar", diff --git a/src/components/agent/chat/utils/buildUserInputSubmitOp.test.ts b/src/components/agent/chat/utils/buildUserInputSubmitOp.test.ts index ef34be4c4..eddb4aa15 100644 --- a/src/components/agent/chat/utils/buildUserInputSubmitOp.test.ts +++ b/src/components/agent/chat/utils/buildUserInputSubmitOp.test.ts @@ -14,6 +14,7 @@ interface ExpectedCurrentTurnStartWire { threadId: string; text: string; imageUrls?: string[]; + mentions?: Array<{ name: string; path: string }>; model?: string; effort?: string; collaborationMode?: CollaborationMode; @@ -72,6 +73,10 @@ function createExpectedTurn( threadId: expected.threadId, input: [ { type: "text" as const, text: expected.text }, + ...(expected.mentions ?? []).map((mention) => ({ + type: "mention" as const, + ...mention, + })), ...(expected.imageUrls ?? []).map((url) => ({ type: "image" as const, url, @@ -177,6 +182,13 @@ describe("buildUserInputSubmitOp", () => { mediaType: "image/png", }, ], + inputMentions: [ + { + type: "mention", + name: "Browser", + path: "plugin://browser", + }, + ], threadId: "thread-social-1", eventName: "agent_stream_x", requestMetadata: { @@ -226,6 +238,7 @@ describe("buildUserInputSubmitOp", () => { expectCurrentUserInputOp(op, { threadId: "thread-social-1", text: "继续生成社媒初稿", + mentions: [{ name: "Browser", path: "plugin://browser" }], imageUrls: ["data:image/png;base64,base64-image"], approvalPolicy: "on-request", sandboxPolicy: "workspace-write", @@ -236,6 +249,7 @@ describe("buildUserInputSubmitOp", () => { expectCurrentTurnStartWire(request, { threadId: "thread-social-1", text: "继续生成社媒初稿", + mentions: [{ name: "Browser", path: "plugin://browser" }], imageUrls: ["data:image/png;base64,base64-image"], approvalPolicy: "on-request", sandboxPolicy: "workspace-write", diff --git a/src/components/agent/chat/utils/buildUserInputSubmitOp.ts b/src/components/agent/chat/utils/buildUserInputSubmitOp.ts index 6291e204e..556fb50b2 100644 --- a/src/components/agent/chat/utils/buildUserInputSubmitOp.ts +++ b/src/components/agent/chat/utils/buildUserInputSubmitOp.ts @@ -11,6 +11,7 @@ import type { AgentSessionExecutionRuntime } from "@/lib/api/agentExecutionRunti import type { CollaborationMode, ModeKind } from "@limecloud/app-server-client"; import type { AgentAccessMode } from "../hooks/agentChatStorage"; import type { SessionModelPreference } from "../hooks/agentChatShared"; +import type { AgentInputMention } from "../hooks/agentChatShared"; import type { MessageImage } from "../types"; import type { ChatToolPreferences } from "./chatToolPreferences"; import { createRuntimePoliciesFromAccessMode } from "./accessModeRuntime"; @@ -34,12 +35,14 @@ export interface BuildUserInputSubmitOpOptions { modelOverride?: string; reasoningEffort?: string; modelCapabilitySummary?: ModelCapabilitySummary | null; + inputMentions?: readonly AgentInputMention[]; } export interface BuildTurnInputOptions { content: string; images: MessageImage[]; modelCapabilitySummary?: ModelCapabilitySummary | null; + inputMentions?: readonly AgentInputMention[]; } function buildCollaborationMode( @@ -64,7 +67,12 @@ function buildCollaborationMode( export function buildTurnInput( options: BuildTurnInputOptions, ): AgentUserInputOp["turn"]["input"] { - const { content, images, modelCapabilitySummary } = options; + const { + content, + images, + inputMentions = [], + modelCapabilitySummary, + } = options; if (modelCapabilitySummary !== undefined) { assertModelInputCapabilityAllowed( modelCapabilitySummary, @@ -78,6 +86,7 @@ export function buildTurnInput( return [ { type: "text", text: content }, + ...inputMentions.map((mention) => ({ ...mention })), ...images.map((image) => ({ type: "image" as const, url: buildMessageImageDataUrl(image), @@ -105,6 +114,7 @@ export function buildUserInputSubmitOp( modelOverride, reasoningEffort, modelCapabilitySummary, + inputMentions, } = options; const turnModel = modelOverride?.trim() || effectiveModel.trim(); @@ -139,7 +149,12 @@ export function buildUserInputSubmitOp( ...(clientUserMessageId?.trim() ? { clientUserMessageId: clientUserMessageId.trim() } : {}), - input: buildTurnInput({ content, images, modelCapabilitySummary }), + input: buildTurnInput({ + content, + images, + inputMentions, + modelCapabilitySummary, + }), ...(collaborationMode ? { collaborationMode } : {}), ...(compaction.shouldSubmitModel ? { model: turnModel } : {}), ...(reasoningEffort?.trim() ? { effort: reasoningEffort.trim() } : {}), diff --git a/src/components/agent/chat/workspace/WorkspacePluginSurface.test.tsx b/src/components/agent/chat/workspace/WorkspacePluginSurface.test.tsx index da23a9b6e..73c76e75a 100644 --- a/src/components/agent/chat/workspace/WorkspacePluginSurface.test.tsx +++ b/src/components/agent/chat/workspace/WorkspacePluginSurface.test.tsx @@ -10,6 +10,14 @@ const embeddedBrowserMocks = vi.hoisted(() => ({ isEmbeddedBrowserHostAvailable: vi.fn(() => true), listenEmbeddedBrowserViewLoadFailed: vi.fn(async () => vi.fn()), listenEmbeddedBrowserViewState: vi.fn(async () => vi.fn()), + loadEmbeddedBrowserViewHtml: vi.fn(async (params) => ({ + viewId: params.viewId, + url: params.sourceUri, + title: "MCP App", + canGoBack: false, + canGoForward: false, + isLoading: false, + })), mountEmbeddedBrowserView: vi.fn(async () => ({ viewId: "plugin-surface-plugin-shell-content-factory-app-standalone", url: "http://127.0.0.1:4199/dashboard", @@ -38,6 +46,20 @@ const embeddedBrowserMocks = vi.hoisted(() => ({ vi.mock("@/lib/api/embeddedBrowser", () => embeddedBrowserMocks); +const mcpApiMocks = vi.hoisted(() => ({ + readResource: vi.fn(), +})); + +vi.mock("@/lib/api/mcp", () => ({ + mcpApi: mcpApiMocks, +})); + +const pluginCatalogMocks = vi.hoisted(() => ({ + listInstalledPluginCatalog: vi.fn(), +})); + +vi.mock("@/lib/api/pluginCatalog", () => pluginCatalogMocks); + vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => { @@ -46,10 +68,17 @@ vi.mock("react-i18next", () => ({ "当前环境没有可用的 Electron WebContentsView bridge。", "agentChat.pluginSurface.hostUnavailableTitle": "宿主不可用", "agentChat.pluginSurface.closeTab": "关闭", + "agentChat.pluginSurface.historyUnavailableBody": + "对话和运行结果仍会保留,但这个历史界面无法再次打开,也不会自动重跑原操作。", + "agentChat.pluginSurface.historyUnavailableStatus": "仅保留历史", + "agentChat.pluginSurface.historyUnavailableTitle": "Plugin 已卸载", "agentChat.pluginSurface.loading": "正在加载", "agentChat.pluginSurface.loadingBody": "正在连接 Plugin。", "agentChat.pluginSurface.loadingTitle": "正在打开 Plugin", "agentChat.pluginSurface.ready": "已连接", + "agentChat.pluginSurface.mcpApp.invalidMimeType": "资源格式不受支持", + "agentChat.pluginSurface.mcpApp.missingHtml": "资源没有可显示内容", + "agentChat.pluginSurface.mcpApp.uriMismatch": "资源身份不一致", }; return copy[key] ?? key; }, @@ -86,6 +115,15 @@ beforeEach(() => { ).IS_REACT_ACT_ENVIRONMENT = true; vi.clearAllMocks(); embeddedBrowserMocks.isEmbeddedBrowserHostAvailable.mockReturnValue(true); + pluginCatalogMocks.listInstalledPluginCatalog.mockResolvedValue({ + generatedAt: "now", + plugins: [{ id: "demo-plugin", enabled: true, installed: true }], + }); + mcpApiMocks.readResource.mockResolvedValue({ + uri: "ui://demo/report.html", + mime_type: "text/html;profile=mcp-app", + text: "
Plugin report
", + }); }); afterEach(() => { @@ -128,8 +166,7 @@ describe("WorkspacePluginSurface", () => { expect(container.textContent).toContain("内容工厂"); expect(embeddedBrowserMocks.mountEmbeddedBrowserView).toHaveBeenCalledWith( expect.objectContaining({ - viewId: - "plugin-surface-plugin-shell-content-factory-app-standalone", + viewId: "plugin-surface-plugin-shell-content-factory-app-standalone", url: "http://127.0.0.1:4199/dashboard", }), ); @@ -143,19 +180,344 @@ describe("WorkspacePluginSurface", () => { embeddedBrowserMocks.destroyEmbeddedBrowserView, ).toHaveBeenCalledWith( "plugin-surface-plugin-shell-content-factory-app-standalone", + embeddedBrowserMocks.mountEmbeddedBrowserView.mock.calls[0]?.[0].leaseId, ); }); it("Electron host 不可用时应 fail closed,不挂载 WebContentsView", async () => { embeddedBrowserMocks.isEmbeddedBrowserHostAvailable.mockReturnValue(false); const container = await renderSurface(); + const frame = container.querySelector( + '[data-testid="workspace-plugin-surface-frame"]', + ); expect(container.textContent).toContain("宿主不可用"); + expect(frame?.getAttribute("data-host-available")).toBe("false"); + expect(frame?.getAttribute("data-mounted")).toBe("false"); expect( embeddedBrowserMocks.mountEmbeddedBrowserView, ).not.toHaveBeenCalled(); }); + it("同一 viewId 的实例应使用独立租约释放原生视图", async () => { + const firstContainer = await renderSurface(); + const secondContainer = await renderSurface(); + expect(embeddedBrowserMocks.mountEmbeddedBrowserView).toHaveBeenCalledTimes( + 2, + ); + + const firstMounted = mountedRoots.find( + (entry) => entry.container === firstContainer, + ); + expect(firstMounted).toBeDefined(); + await act(async () => { + firstMounted?.root.unmount(); + await Promise.resolve(); + }); + firstContainer.remove(); + const firstIndex = mountedRoots.findIndex( + (entry) => entry.container === firstContainer, + ); + if (firstIndex >= 0) { + mountedRoots.splice(firstIndex, 1); + } + + const mountLeases = embeddedBrowserMocks.mountEmbeddedBrowserView.mock.calls + .map(([params]) => params.leaseId) + .filter(Boolean); + expect(new Set(mountLeases).size).toBe(2); + expect( + embeddedBrowserMocks.destroyEmbeddedBrowserView, + ).toHaveBeenCalledWith( + "plugin-surface-plugin-shell-content-factory-app-standalone", + mountLeases[0], + ); + + const secondMounted = mountedRoots.find( + (entry) => entry.container === secondContainer, + ); + await act(async () => { + secondMounted?.root.unmount(); + await Promise.resolve(); + }); + secondContainer.remove(); + const secondIndex = mountedRoots.findIndex( + (entry) => entry.container === secondContainer, + ); + if (secondIndex >= 0) { + mountedRoots.splice(secondIndex, 1); + } + + expect( + embeddedBrowserMocks.destroyEmbeddedBrowserView, + ).toHaveBeenCalledTimes(2); + expect( + embeddedBrowserMocks.destroyEmbeddedBrowserView, + ).toHaveBeenCalledWith( + "plugin-surface-plugin-shell-content-factory-app-standalone", + mountLeases[1], + ); + }); + + it("应从 canonical MCP resource 读取并受控加载 MCP App HTML", async () => { + const mcpSurface: WorkspacePluginSurfaceDescriptor = { + appId: "demo-plugin", + title: "demo-plugin", + containerId: "mcp-app-item-1", + activeStrategy: "webContentsView", + supportedStrategies: ["webContentsView"], + mcpApp: { + resourceUri: "ui://demo/report.html", + serverName: "plugin__demo__server", + toolItemId: "item-1", + }, + }; + + await renderSurface({ + runtimeOwner: { sessionId: "session-1", threadId: "thread-1" }, + surface: mcpSurface, + }); + + expect(mcpApiMocks.readResource).toHaveBeenCalledWith( + "plugin__demo__server", + "ui://demo/report.html", + { sessionId: "session-1", threadId: "thread-1" }, + ); + expect( + embeddedBrowserMocks.loadEmbeddedBrowserViewHtml, + ).toHaveBeenCalledWith( + expect.objectContaining({ + html: "
Plugin report
", + source: "mcpApp", + sourceUri: "ui://demo/report.html", + viewId: "plugin-surface-mcp-app-item-1", + }), + ); + expect( + embeddedBrowserMocks.navigateEmbeddedBrowserView, + ).not.toHaveBeenCalledWith( + expect.objectContaining({ url: "ui://demo/report.html" }), + ); + }); + + it("Plugin 已卸载时应保留历史状态且不重启 MCP App", async () => { + pluginCatalogMocks.listInstalledPluginCatalog.mockResolvedValueOnce({ + generatedAt: "now", + plugins: [], + }); + const mcpSurface: WorkspacePluginSurfaceDescriptor = { + appId: "demo-plugin", + title: "demo-plugin", + containerId: "mcp-app-item-uninstalled", + activeStrategy: "webContentsView", + supportedStrategies: ["webContentsView"], + mcpApp: { + resourceUri: "ui://demo/report.html", + serverName: "plugin__demo__server", + toolItemId: "item-uninstalled", + }, + }; + + const container = await renderSurface({ + runtimeOwner: { sessionId: "session-1", threadId: "thread-1" }, + surface: mcpSurface, + }); + const frame = container.querySelector( + '[data-testid="workspace-plugin-surface-frame"]', + ); + + expect(frame?.getAttribute("data-plugin-availability")).toBe("uninstalled"); + expect(frame?.getAttribute("data-mounted")).toBe("false"); + expect( + container.querySelector( + '[data-testid="workspace-plugin-surface-history-unavailable"]', + ), + ).not.toBeNull(); + expect(container.textContent).toContain("Plugin 已卸载"); + expect(container.textContent).toContain("不会自动重跑原操作"); + expect( + embeddedBrowserMocks.mountEmbeddedBrowserView, + ).not.toHaveBeenCalled(); + expect(mcpApiMocks.readResource).not.toHaveBeenCalled(); + expect( + embeddedBrowserMocks.loadEmbeddedBrowserViewHtml, + ).not.toHaveBeenCalled(); + }); + + it("应在空 URL WebContentsView 异步挂载完成后读取 MCP App resource", async () => { + let resolveMount: + | ((value: { + viewId: string; + url: string; + title: string; + canGoBack: boolean; + canGoForward: boolean; + isLoading: boolean; + }) => void) + | undefined; + embeddedBrowserMocks.mountEmbeddedBrowserView.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveMount = resolve; + }), + ); + const mcpSurface: WorkspacePluginSurfaceDescriptor = { + appId: "demo-plugin", + title: "demo-plugin", + containerId: "mcp-app-item-async", + activeStrategy: "webContentsView", + supportedStrategies: ["webContentsView"], + mcpApp: { + resourceUri: "ui://demo/report.html", + serverName: "plugin__demo__server", + toolItemId: "item-async", + }, + }; + + await renderSurface({ + runtimeOwner: { sessionId: "session-1", threadId: "thread-1" }, + surface: mcpSurface, + }); + + const frame = document.querySelector( + '[data-testid="workspace-plugin-surface-frame"]', + ); + expect(frame).not.toBeNull(); + expect(frame?.getAttribute("data-host-available")).toBe("true"); + expect(frame?.getAttribute("data-mounted")).toBe("false"); + expect(frame?.getAttribute("data-runtime-session-id")).toBe("session-1"); + expect(frame?.getAttribute("data-runtime-thread-id")).toBe("thread-1"); + expect(frame?.getAttribute("data-mcp-server")).toBe("plugin__demo__server"); + expect(frame?.getAttribute("data-mcp-resource-uri")).toBe( + "ui://demo/report.html", + ); + expect(mcpApiMocks.readResource).not.toHaveBeenCalled(); + await act(async () => { + resolveMount?.({ + viewId: "plugin-surface-mcp-app-item-async", + url: "", + title: "", + canGoBack: false, + canGoForward: false, + isLoading: false, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(frame?.getAttribute("data-mounted")).toBe("true"); + expect(mcpApiMocks.readResource).toHaveBeenCalledWith( + "plugin__demo__server", + "ui://demo/report.html", + { sessionId: "session-1", threadId: "thread-1" }, + ); + expect( + embeddedBrowserMocks.loadEmbeddedBrowserViewHtml, + ).toHaveBeenCalledWith( + expect.objectContaining({ + sourceUri: "ui://demo/report.html", + viewId: "plugin-surface-mcp-app-item-async", + }), + ); + }); + + it("descriptor identity 变化时不应重复读取同一 MCP App resource", async () => { + const mcpSurface: WorkspacePluginSurfaceDescriptor = { + appId: "demo-plugin", + title: "demo-plugin", + containerId: "mcp-app-item-1", + activeStrategy: "webContentsView", + supportedStrategies: ["webContentsView"], + mcpApp: { + resourceUri: "ui://demo/report.html", + serverName: "plugin__demo__server", + toolItemId: "item-1", + }, + }; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push({ root, container }); + + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + expect(mcpApiMocks.readResource).toHaveBeenCalledTimes(1); + expect( + embeddedBrowserMocks.loadEmbeddedBrowserViewHtml, + ).toHaveBeenCalledTimes(1); + }); + + it("MCP App resource 读取期间重渲染不应取消同一 identity 的加载", async () => { + let resolveResource: + | ((value: { uri: string; mime_type: string; text: string }) => void) + | undefined; + mcpApiMocks.readResource.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveResource = resolve; + }), + ); + const mcpSurface: WorkspacePluginSurfaceDescriptor = { + appId: "demo-plugin", + title: "demo-plugin", + containerId: "mcp-app-item-pending", + activeStrategy: "webContentsView", + supportedStrategies: ["webContentsView"], + mcpApp: { + resourceUri: "ui://demo/report.html", + serverName: "plugin__demo__server", + toolItemId: "item-pending", + }, + }; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push({ root, container }); + + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mcpApiMocks.readResource).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + resolveResource?.({ + uri: "ui://demo/report.html", + mime_type: "text/html;profile=mcp-app", + text: "
Plugin report
", + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mcpApiMocks.readResource).toHaveBeenCalledTimes(1); + expect( + embeddedBrowserMocks.loadEmbeddedBrowserViewHtml, + ).toHaveBeenCalledTimes(1); + }); + it("应在右侧 appSurface 内渲染多个 Plugin 实例 tab,并支持聚焦和关闭", async () => { const onSelectSurface = vi.fn(); const onCloseSurface = vi.fn(); @@ -167,9 +529,7 @@ describe("WorkspacePluginSurface", () => { }); expect( - container.querySelector( - '[data-testid="workspace-plugin-surface-tabs"]', - ), + container.querySelector('[data-testid="workspace-plugin-surface-tabs"]'), ).not.toBeNull(); expect(container.textContent).toContain("内容工厂"); expect(container.textContent).toContain("提示词实验室"); diff --git a/src/components/agent/chat/workspace/WorkspacePluginSurface.tsx b/src/components/agent/chat/workspace/WorkspacePluginSurface.tsx index e0c6a651e..556527ddc 100644 --- a/src/components/agent/chat/workspace/WorkspacePluginSurface.tsx +++ b/src/components/agent/chat/workspace/WorkspacePluginSurface.tsx @@ -13,19 +13,27 @@ import { isEmbeddedBrowserHostAvailable, listenEmbeddedBrowserViewLoadFailed, listenEmbeddedBrowserViewState, + loadEmbeddedBrowserViewHtml, mountEmbeddedBrowserView, navigateEmbeddedBrowserView, setEmbeddedBrowserViewBounds, type EmbeddedBrowserBounds, type EmbeddedBrowserViewState, } from "@/lib/api/embeddedBrowser"; +import { mcpApi } from "@/lib/api/mcp"; +import { listInstalledPluginCatalog } from "@/lib/api/pluginCatalog"; import { selectWorkspacePluginSurfaceDescriptor, type WorkspacePluginSurfaceDescriptor, } from "./workspacePluginSurfaceModel"; +import { + buildWorkspaceMcpAppHtmlParams, + WorkspaceMcpAppResourceError, +} from "./workspaceMcpAppResource"; interface WorkspacePluginSurfaceProps { activeContainerId?: string | null; + runtimeOwner?: { sessionId: string; threadId: string } | null; onCloseSurface?: (surface: WorkspacePluginSurfaceDescriptor) => void; onSelectSurface?: (surface: WorkspacePluginSurfaceDescriptor) => void; surface?: WorkspacePluginSurfaceDescriptor | null; @@ -37,8 +45,18 @@ type PluginSurfaceTranslation = ( options?: Record, ) => string; +type McpAppPluginAvailability = + | "available" + | "checking" + | "not-applicable" + | "uninstalled" + | "unknown"; + +let embeddedBrowserViewLeaseSequence = 0; + export function WorkspacePluginSurface({ activeContainerId, + runtimeOwner, onCloseSurface, onSelectSurface, surface, @@ -48,9 +66,7 @@ export function WorkspacePluginSurface({ const dynamicT = t as PluginSurfaceTranslation; const surfaceList = useMemo( () => - normalizeWorkspacePluginSurfaces( - surfaces ?? (surface ? [surface] : []), - ), + normalizeWorkspacePluginSurfaces(surfaces ?? (surface ? [surface] : [])), [surface, surfaces], ); const activeSurface = selectWorkspacePluginSurfaceDescriptor( @@ -124,6 +140,7 @@ export function WorkspacePluginSurface({ ))} @@ -134,9 +151,11 @@ export function WorkspacePluginSurface({ function WorkspacePluginSurfaceFrame({ active, + runtimeOwner, surface, }: { active: boolean; + runtimeOwner?: { sessionId: string; threadId: string } | null; surface: WorkspacePluginSurfaceDescriptor; }): ReactElement { const { t } = useTranslation("agent"); @@ -150,12 +169,55 @@ function WorkspacePluginSurfaceFrame({ const activeRef = useRef(active); const lastBoundsRef = useRef(null); const lastVisibleRef = useRef(null); + const loadedMcpAppIdentityRef = useRef(null); + const translationRef = useRef(dynamicT); const latestEntryUrlRef = useRef(surface.entryUrl); activeRef.current = active; + translationRef.current = dynamicT; latestEntryUrlRef.current = surface.entryUrl; const [state, setState] = useState(null); const [errorText, setErrorText] = useState(null); + const [mounted, setMounted] = useState(false); const hostAvailable = isEmbeddedBrowserHostAvailable(); + const [mcpAppPluginAvailability, setMcpAppPluginAvailability] = + useState( + surface.mcpApp ? "checking" : "not-applicable", + ); + const runtimeOwnerSessionId = runtimeOwner?.sessionId; + const runtimeOwnerThreadId = runtimeOwner?.threadId; + const mcpAppPluginUninstalled = mcpAppPluginAvailability === "uninstalled"; + const embeddedHostReady = + hostAvailable && + mcpAppPluginAvailability !== "checking" && + !mcpAppPluginUninstalled; + + useEffect(() => { + if (!surface.mcpApp) { + setMcpAppPluginAvailability("not-applicable"); + return; + } + + let cancelled = false; + setMcpAppPluginAvailability("checking"); + void listInstalledPluginCatalog() + .then(({ plugins }) => { + if (!cancelled) { + setMcpAppPluginAvailability( + plugins.some((plugin) => plugin.id === surface.appId) + ? "available" + : "uninstalled", + ); + } + }) + .catch(() => { + if (!cancelled) { + setMcpAppPluginAvailability("unknown"); + } + }); + return () => { + cancelled = true; + }; + }, [surface.appId, surface.mcpApp?.resourceUri, surface.mcpApp?.serverName]); const syncBounds = useCallback( async (force = false) => { @@ -191,14 +253,18 @@ function WorkspacePluginSurfaceFrame({ ); useEffect(() => { - if (!hostAvailable) { + if (!embeddedHostReady) { mountedRef.current = false; + setMounted(false); setState(null); setErrorText(null); return; } let cancelled = false; + const leaseId = createEmbeddedBrowserViewLeaseId(viewId); + mountedRef.current = false; + setMounted(false); const isActive = activeRef.current; const element = isActive ? viewportRef.current : null; const bounds = element @@ -210,7 +276,8 @@ function WorkspacePluginSurfaceFrame({ void mountEmbeddedBrowserView({ viewId, - url: latestEntryUrlRef.current, + leaseId, + ...(latestEntryUrlRef.current ? { url: latestEntryUrlRef.current } : {}), bounds, visible, }) @@ -219,6 +286,7 @@ function WorkspacePluginSurfaceFrame({ return; } mountedRef.current = true; + setMounted(true); setState(nextState); setErrorText(null); }) @@ -231,9 +299,9 @@ function WorkspacePluginSurfaceFrame({ return () => { cancelled = true; mountedRef.current = false; - void destroyEmbeddedBrowserView(viewId).catch(() => undefined); + void destroyEmbeddedBrowserView(viewId, leaseId).catch(() => undefined); }; - }, [hostAvailable, viewId]); + }, [embeddedHostReady, viewId]); useEffect(() => { if (!hostAvailable || !mountedRef.current) { @@ -246,6 +314,7 @@ function WorkspacePluginSurfaceFrame({ if ( !hostAvailable || !mountedRef.current || + !surface.entryUrl || state?.url === surface.entryUrl ) { return; @@ -264,6 +333,69 @@ function WorkspacePluginSurfaceFrame({ }); }, [hostAvailable, state?.url, surface.entryUrl, viewId]); + useEffect(() => { + const serverName = surface.mcpApp?.serverName; + const resourceUri = surface.mcpApp?.resourceUri; + if (!hostAvailable || !mounted || !serverName || !resourceUri) { + return; + } + const resourceIdentity = [ + runtimeOwnerSessionId ?? "", + runtimeOwnerThreadId ?? "", + serverName, + resourceUri, + ].join("\u0000"); + if (loadedMcpAppIdentityRef.current === resourceIdentity) { + return; + } + let cancelled = false; + const resourceOwner = + runtimeOwnerSessionId && runtimeOwnerThreadId + ? { + sessionId: runtimeOwnerSessionId, + threadId: runtimeOwnerThreadId, + } + : undefined; + void mcpApi + .readResource(serverName, resourceUri, resourceOwner) + .then((content) => { + if (cancelled) { + return null; + } + return loadEmbeddedBrowserViewHtml( + buildWorkspaceMcpAppHtmlParams({ + content, + expectedUri: resourceUri, + viewId, + }), + ); + }) + .then((nextState) => { + if (cancelled || !nextState) { + return; + } + loadedMcpAppIdentityRef.current = resourceIdentity; + setState(nextState); + setErrorText(null); + }) + .catch((error) => { + if (!cancelled) { + setErrorText(resolveMcpAppLoadError(error, translationRef.current)); + } + }); + return () => { + cancelled = true; + }; + }, [ + hostAvailable, + mounted, + surface.mcpApp?.resourceUri, + surface.mcpApp?.serverName, + runtimeOwnerSessionId, + runtimeOwnerThreadId, + viewId, + ]); + useEffect(() => { if (!hostAvailable) { return; @@ -347,6 +479,14 @@ function WorkspacePluginSurfaceFrame({ className={`absolute inset-0 flex h-full min-h-0 flex-col bg-[color:var(--lime-surface)] ${ active ? "" : "hidden" }`} + data-active={active ? "true" : "false"} + data-host-available={hostAvailable ? "true" : "false"} + data-mcp-resource-uri={surface.mcpApp?.resourceUri} + data-mcp-server={surface.mcpApp?.serverName} + data-mounted={mounted ? "true" : "false"} + data-plugin-availability={mcpAppPluginAvailability} + data-runtime-session-id={runtimeOwnerSessionId} + data-runtime-thread-id={runtimeOwnerThreadId} data-testid="workspace-plugin-surface-frame" data-view-id={viewId} > @@ -359,9 +499,11 @@ function WorkspacePluginSurfaceFrame({ {surface.title}
- {state?.isLoading - ? dynamicT("agentChat.pluginSurface.loading") - : dynamicT("agentChat.pluginSurface.ready")} + {mcpAppPluginUninstalled + ? dynamicT("agentChat.pluginSurface.historyUnavailableStatus") + : mcpAppPluginAvailability === "checking" || state?.isLoading + ? dynamicT("agentChat.pluginSurface.loading") + : dynamicT("agentChat.pluginSurface.ready")}
@@ -377,6 +519,13 @@ function WorkspacePluginSurfaceFrame({ title={dynamicT("agentChat.pluginSurface.hostUnavailableTitle")} body={dynamicT("agentChat.pluginSurface.hostUnavailableBody")} /> + ) : mcpAppPluginUninstalled ? ( + ) : !state && !errorText ? ( +
@@ -474,3 +628,18 @@ function boundsEqual( function sanitizeViewId(value: string): string { return value.trim().replace(/[^a-zA-Z0-9_-]/g, "-") || "default"; } + +function createEmbeddedBrowserViewLeaseId(viewId: string): string { + embeddedBrowserViewLeaseSequence += 1; + return `${viewId}:${embeddedBrowserViewLeaseSequence}`; +} + +function resolveMcpAppLoadError( + error: unknown, + t: PluginSurfaceTranslation, +): string { + if (error instanceof WorkspaceMcpAppResourceError) { + return t(`agentChat.pluginSurface.mcpApp.${error.code}`); + } + return error instanceof Error ? error.message : String(error); +} diff --git a/src/components/agent/chat/workspace/WorkspaceRightSurfaceHostRuntime.tsx b/src/components/agent/chat/workspace/WorkspaceRightSurfaceHostRuntime.tsx index 4b575b9b2..9820382c5 100644 --- a/src/components/agent/chat/workspace/WorkspaceRightSurfaceHostRuntime.tsx +++ b/src/components/agent/chat/workspace/WorkspaceRightSurfaceHostRuntime.tsx @@ -63,6 +63,7 @@ export interface RenderWorkspaceRightSurfaceHostRuntimeParams { rightSurfaceTraceEnabled: boolean; runtimeWorkspaceId?: string | null; sceneSessionId?: string | null; + sceneThreadId?: string | null; onArticleActionIntent: ArticleEditorRightSurfaceProps["onActionIntent"]; onArticleMarkdownChange: ArticleEditorRightSurfaceProps["onArticleMarkdownChange"]; onArticleSelectedObjectChange: ArticleEditorRightSurfaceProps["onSelectedObjectChange"]; @@ -104,6 +105,7 @@ export function renderWorkspaceRightSurfaceHostRuntime({ rightSurfaceTraceEnabled, runtimeWorkspaceId, sceneSessionId, + sceneThreadId, onArticleActionIntent, onArticleMarkdownChange, onArticleSelectedObjectChange, @@ -123,6 +125,11 @@ export function renderWorkspaceRightSurfaceHostRuntime({ appSurface: () => ( void): () => void { }; } +export function scheduleAfterCommit(callback: () => void): () => void { + let cancelled = false; + const run = () => { + if (!cancelled) { + callback(); + } + }; + + if (typeof queueMicrotask === "function") { + queueMicrotask(run); + } else { + void Promise.resolve().then(run); + } + + return () => { + cancelled = true; + }; +} + export function mergeSessionRecentMetadataSyncPriority( current: SessionRecentMetadataSyncPriority, next?: SessionRecentMetadataSyncPriority, diff --git a/src/components/agent/chat/workspace/useAgentChatWorkspaceSceneRuntime.tsx b/src/components/agent/chat/workspace/useAgentChatWorkspaceSceneRuntime.tsx index 4f5932cdf..fa486dcc1 100644 --- a/src/components/agent/chat/workspace/useAgentChatWorkspaceSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useAgentChatWorkspaceSceneRuntime.tsx @@ -68,7 +68,7 @@ export function useAgentChatWorkspaceSceneRuntime({ clearMessages, deleteMessage, editMessage, handlePermissionResponse, submittedActionsInFlight, sessionHistoryWindow, isAutoRestoringSession, isSessionHydrating, sessionId, originalSwitchTopic, loadFullSessionHistory, refreshSessionReadModel, workspacePathMissing, workspaceHealthError, combinedSkillsLoading, expertPanelRequestMetadata, expertPanelRuntimeKey, expertSkillRefsOverride, expertWorkspaceSkillRuntimeEnableBindings, expertWorkspaceSkillRuntimeEnableRefs, handleEnableExpertWorkspaceSkillRuntime, handleExpertSkillRefsChange, - handlePluginSuggestionsNeeded, handleThreadExpertProfileSwitch, workspacePluginInputSuggestions, workspacePluginRuntimeContext, workspaceSkillBindings, topicById, effectiveChatToolPreferences, canonicalChildren, + handlePluginSuggestionsNeeded, handleThreadExpertProfileSwitch, workspacePluginInputSuggestions, workspacePluginSuggestionsError, workspacePluginSuggestionsLoading, workspacePluginRuntimeContext, workspaceSkillBindings, topicById, effectiveChatToolPreferences, canonicalChildren, currentSessionTitle, handleStopSending, handleOpenSubagentSession, currentImageWorkbenchState, imageWorkbenchSessionKey, updateCurrentImageWorkbenchState, artifacts, artifactDisplayState, artifactViewMode, browserAssistLaunching, browserAssistSessionRef, browserAssistSessionState, currentCanvasArtifact, currentBrowserAssistScopeKey, displayedCanvasArtifact, handleArtifactViewModeChange, handleOpenBrowserRuntimeForBrowserAssist, settledWorkbenchArtifacts, contextHarnessRuntime, effectiveThreadItems, harnessState, inputbarIsSending, rightSurfaceLocalState, contextWorkspace, @@ -143,9 +143,8 @@ export function useAgentChatWorkspaceSceneRuntime({ initialInputCapability: effectiveInitialInputCapability, initialKnowledgePackSelection, pluginSuggestions: workspacePluginInputSuggestions, - pluginSuggestionsError: - workspacePluginRuntimeContext.error?.message ?? null, - pluginSuggestionsLoading: workspacePluginRuntimeContext.loading, + pluginSuggestionsError: workspacePluginSuggestionsError?.message ?? null, + pluginSuggestionsLoading: workspacePluginSuggestionsLoading, onPluginSuggestionsNeeded: handlePluginSuggestionsNeeded, setChatToolPreferences, objectiveEnabled: inputbarObjectiveModeEnabled, @@ -383,12 +382,14 @@ export function useAgentChatWorkspaceSceneRuntime({ sceneIsSending, sceneLayoutMode, sceneSessionId, + sceneThreadId: sceneThreadRead?.thread_id ?? null, sessionId, showHarnessToggle, suppressHomeNavbarUtilityActions, taskCenterHomeHotpathActive: shouldRenderTaskCenterEmbeddedHome || Boolean(taskCenterDraftSendRequest || homePendingPreviewRequest), + threadItems: sceneThreadItems, setExpertInfoPanelCollapsed, setHarnessPanelVisible, setLayoutMode, @@ -399,6 +400,7 @@ export function useAgentChatWorkspaceSceneRuntime({ preferredServiceSkillResultFileTarget, runtimeWorkspaceId, sceneSessionId, + sceneThreadId: sceneThreadRead?.thread_id ?? null, onOpenArticlePreviewArtifact: openWorkspaceArtifactInWorkbench, onOpenBrowserRuntimeForBrowserAssist: handleOpenBrowserRuntimeForBrowserAssist, @@ -478,8 +480,8 @@ export function useAgentChatWorkspaceSceneRuntime({ workspacePluginHistoryRestoreLandingCard, pluginSuggestions: workspacePluginInputSuggestions, pluginSuggestionsError: - workspacePluginRuntimeContext.error?.message ?? null, - pluginSuggestionsLoading: workspacePluginRuntimeContext.loading, + workspacePluginSuggestionsError?.message ?? null, + pluginSuggestionsLoading: workspacePluginSuggestionsLoading, projectCharacters: projectMemory?.characters || [], projectConversationGroups, projectId: projectId ?? null, diff --git a/src/components/agent/chat/workspace/useAgentChatWorkspaceSetupRuntime.ts b/src/components/agent/chat/workspace/useAgentChatWorkspaceSetupRuntime.ts index bf6fe0305..5a5f3ee49 100644 --- a/src/components/agent/chat/workspace/useAgentChatWorkspaceSetupRuntime.ts +++ b/src/components/agent/chat/workspace/useAgentChatWorkspaceSetupRuntime.ts @@ -243,6 +243,8 @@ export function useAgentChatWorkspaceSetupRuntime({ handlePluginSuggestionsNeeded, handleThreadExpertProfileSwitch, workspacePluginInputSuggestions, + workspacePluginSuggestionsError, + workspacePluginSuggestionsLoading, workspacePluginRuntimeContext, workspaceRequestMetadataWithExpertSkills, workspaceSkillBindings, @@ -698,6 +700,8 @@ export function useAgentChatWorkspaceSetupRuntime({ handlePluginSuggestionsNeeded, handleThreadExpertProfileSwitch, workspacePluginInputSuggestions, + workspacePluginSuggestionsError, + workspacePluginSuggestionsLoading, workspacePluginRuntimeContext, workspaceRequestMetadataWithExpertSkills, workspaceSkillBindings, diff --git a/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.ts b/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.ts index c8b72da74..7c3b542db 100644 --- a/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.ts +++ b/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.ts @@ -21,6 +21,7 @@ import type { WorkspaceHandleSend } from "./useWorkspaceSendActions"; import { createTaskCenterDraftSendRequestId, resolveTaskCenterDraftSendTitle, + scheduleAfterCommit, scheduleAfterNextPaint, type TaskCenterDraftTab, } from "./agentChatWorkspaceHelpers"; @@ -291,7 +292,7 @@ export function useTaskCenterEmptyStateSendRuntime({ return; } - scheduleAfterNextPaint(() => { + scheduleAfterCommit(() => { setTaskCenterDraftTabs((current) => markTaskCenterDraftTabRunning({ current, @@ -436,7 +437,7 @@ export function useTaskCenterEmptyStateSendRuntime({ source: "empty-state", workspaceId: taskCenterWorkspaceId, }); - scheduleAfterNextPaint(() => { + scheduleAfterCommit(() => { recordAgentUiPerformanceMetric("homeInput.sendDispatch.start", { elapsedMs: Date.now() - submittedAt, requestId, @@ -564,7 +565,7 @@ export function useTaskCenterEmptyStateSendRuntime({ setTaskCenterDraftSendRequest(previewRequest); setHomePendingPreviewRequest(previewRequest); }); - scheduleAfterNextPaint(() => { + scheduleAfterCommit(() => { recordAgentUiPerformanceMetric("homeInput.sendDispatch.start", { elapsedMs: Date.now() - submittedAt, requestId, diff --git a/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.unit.test.ts b/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.unit.test.ts index 61554ae26..8ce54f351 100644 --- a/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.unit.test.ts +++ b/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.unit.test.ts @@ -1244,6 +1244,25 @@ describe("useTaskCenterEmptyStateSendRuntime", () => { expect(runtime.setHomePendingPreviewRequest).toHaveBeenCalledTimes(1); }); + it("首页 direct dispatch 在同步 preview 提交后的 microtask 中启动,不等待下一帧", async () => { + const runtime = mountEmptyStateSendRuntime({ + activeDraftTabId: null, + hasDisplayMessages: false, + sessionId: null, + }); + + act(() => { + runtime.send({ textOverride: "立即派发" }); + }); + + expect(runtime.handleSend).not.toHaveBeenCalled(); + await act(async () => { + await Promise.resolve(); + }); + + expect(runtime.handleSend).toHaveBeenCalledTimes(1); + }); + it("Task Center 首页首发成功后拿到真实 session 时应立即接管前台会话", async () => { const onNonMaterializedSessionReady = vi.fn(); const runtime = mountEmptyStateSendRuntime({ diff --git a/src/components/agent/chat/workspace/useWorkspaceExpertSkillPanelRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceExpertSkillPanelRuntime.ts index af84b6094..0deabcbc5 100644 --- a/src/components/agent/chat/workspace/useWorkspaceExpertSkillPanelRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceExpertSkillPanelRuntime.ts @@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { ExpertAgentLaunchParams } from "@/types/page"; import { useExpertWorkspaceSkillRuntime } from "./useExpertWorkspaceSkillRuntime"; import { useWorkspaceExpertAgentLaunchSyncRuntime } from "./useWorkspaceExpertAgentLaunchSyncRuntime"; +import { useWorkspacePluginCatalogSuggestions } from "./useWorkspacePluginCatalogSuggestions"; import { useWorkspacePluginRuntimeContext } from "./useWorkspacePluginRuntimeContext"; -import { buildWorkspacePluginInputSuggestions } from "./workspacePluginInputSuggestions"; import { resolveExpertPanelRequestMetadata, resolveSessionExpertRequestMetadata, @@ -118,22 +118,16 @@ export function useWorkspaceExpertSkillPanelRuntime({ const [pluginSuggestionsEnabled, setPluginSuggestionsEnabled] = useState(false); const workspacePluginRuntimeContext = useWorkspacePluginRuntimeContext({ - enabled: pluginSuggestionsEnabled, requestMetadata: workspaceRequestMetadataWithExpertSkills ?? undefined, }); - const refreshWorkspacePluginRuntimeContext = - workspacePluginRuntimeContext.refresh; + const workspacePluginCatalogSuggestions = + useWorkspacePluginCatalogSuggestions({ enabled: pluginSuggestionsEnabled }); + const refreshWorkspacePluginCatalogSuggestions = + workspacePluginCatalogSuggestions.refresh; const handlePluginSuggestionsNeeded = useCallback(() => { setPluginSuggestionsEnabled(true); - refreshWorkspacePluginRuntimeContext(); - }, [refreshWorkspacePluginRuntimeContext]); - const workspacePluginInputSuggestions = useMemo( - () => - buildWorkspacePluginInputSuggestions( - workspacePluginRuntimeContext.context, - ), - [workspacePluginRuntimeContext.context], - ); + refreshWorkspacePluginCatalogSuggestions(); + }, [refreshWorkspacePluginCatalogSuggestions]); return { combinedSkillsLoading, @@ -151,7 +145,11 @@ export function useWorkspaceExpertSkillPanelRuntime({ handleExpertSkillRefsChange, handlePluginSuggestionsNeeded, handleThreadExpertProfileSwitch, - workspacePluginInputSuggestions, + workspacePluginInputSuggestions: + workspacePluginCatalogSuggestions.suggestions, + workspacePluginSuggestionsError: workspacePluginCatalogSuggestions.error, + workspacePluginSuggestionsLoading: + workspacePluginCatalogSuggestions.loading, workspacePluginRuntimeContext, workspaceRequestMetadataWithExpertSkills, workspaceSkillBindings, diff --git a/src/components/agent/chat/workspace/useWorkspaceInitialSessionNavigation.test.tsx b/src/components/agent/chat/workspace/useWorkspaceInitialSessionNavigation.test.tsx index db9cdf6d4..d4792947a 100644 --- a/src/components/agent/chat/workspace/useWorkspaceInitialSessionNavigation.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceInitialSessionNavigation.test.tsx @@ -435,7 +435,9 @@ describe("useWorkspaceInitialSessionNavigation", () => { }); await flushEffects(); - expect(switchTopic).toHaveBeenCalledWith("session-current", undefined); + expect(switchTopic).toHaveBeenCalledWith("session-current", { + forceRefresh: true, + }); }); it("任务中心首发已切到新会话后不应恢复旧 initialSessionId", async () => { diff --git a/src/components/agent/chat/workspace/useWorkspaceInitialSessionNavigation.ts b/src/components/agent/chat/workspace/useWorkspaceInitialSessionNavigation.ts index b1cddc647..9d9c2d210 100644 --- a/src/components/agent/chat/workspace/useWorkspaceInitialSessionNavigation.ts +++ b/src/components/agent/chat/workspace/useWorkspaceInitialSessionNavigation.ts @@ -397,6 +397,7 @@ export function useWorkspaceInitialSessionNavigation({ logAgentDebug("AgentChatPage", "initialSessionNavigation.start", { currentSessionId: normalizedCurrentSessionId, forceRefresh: + hasExplicitNavigationRequest || shouldHydrateMatchedSession || resolvedSwitchOptions?.forceRefresh === true, initialSessionId: normalizedInitialSessionId, @@ -408,7 +409,8 @@ export function useWorkspaceInitialSessionNavigation({ }); const switchOptions: InitialSessionSwitchOptions = { - ...(shouldHydrateMatchedSession || + ...(hasExplicitNavigationRequest || + shouldHydrateMatchedSession || resolvedSwitchOptions?.forceRefresh === true ? { forceRefresh: true } : {}), diff --git a/src/components/agent/chat/workspace/useWorkspacePluginCatalogSuggestions.ts b/src/components/agent/chat/workspace/useWorkspacePluginCatalogSuggestions.ts new file mode 100644 index 000000000..515fa4853 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspacePluginCatalogSuggestions.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { + AppServerPluginCatalogListResponse, + AppServerPluginCatalogSummary, +} from "@/lib/api/appServerTypes"; +import { + PLUGIN_CATALOG_CHANGED_EVENT, + listInstalledPluginCatalog, +} from "@/lib/api/pluginCatalog"; +import type { InputbarPluginCapability } from "../components/Inputbar/pluginInputCapability"; +import { buildWorkspacePluginCatalogSuggestions } from "./workspacePluginCatalogSuggestions"; + +const EMPTY_PLUGINS: readonly AppServerPluginCatalogSummary[] = []; + +export interface UseWorkspacePluginCatalogSuggestionsOptions { + enabled?: boolean; + listInstalled?: () => Promise; +} + +export interface UseWorkspacePluginCatalogSuggestionsResult { + suggestions: InputbarPluginCapability[]; + loading: boolean; + error: Error | null; + refresh: () => void; +} + +export function useWorkspacePluginCatalogSuggestions({ + enabled = false, + listInstalled = listInstalledPluginCatalog, +}: UseWorkspacePluginCatalogSuggestionsOptions): UseWorkspacePluginCatalogSuggestionsResult { + const [plugins, setPlugins] = + useState(EMPTY_PLUGINS); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + const refresh = useCallback(() => { + setRefreshKey((value) => value + 1); + }, []); + + useEffect(() => { + let disposed = false; + let loadSequence = 0; + + if (!enabled) { + setPlugins(EMPTY_PLUGINS); + setLoading(false); + setError(null); + return () => { + disposed = true; + }; + } + + const load = async () => { + const currentSequence = (loadSequence += 1); + setLoading(true); + try { + const result = await listInstalled(); + if (!disposed && currentSequence === loadSequence) { + setPlugins(result.plugins); + setError(null); + } + } catch (caught) { + if (!disposed && currentSequence === loadSequence) { + setPlugins(EMPTY_PLUGINS); + setError( + caught instanceof Error ? caught : new Error(String(caught)), + ); + } + } finally { + if (!disposed && currentSequence === loadSequence) { + setLoading(false); + } + } + }; + + void load(); + if (typeof window !== "undefined") { + window.addEventListener(PLUGIN_CATALOG_CHANGED_EVENT, load); + } + return () => { + disposed = true; + if (typeof window !== "undefined") { + window.removeEventListener(PLUGIN_CATALOG_CHANGED_EVENT, load); + } + }; + }, [enabled, listInstalled, refreshKey]); + + const suggestions = useMemo( + () => buildWorkspacePluginCatalogSuggestions(plugins), + [plugins], + ); + + return { suggestions, loading, error, refresh }; +} diff --git a/src/components/agent/chat/workspace/useWorkspacePluginCatalogSuggestions.unit.test.tsx b/src/components/agent/chat/workspace/useWorkspacePluginCatalogSuggestions.unit.test.tsx new file mode 100644 index 000000000..f3730c96c --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspacePluginCatalogSuggestions.unit.test.tsx @@ -0,0 +1,105 @@ +import { act, useEffect } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AppServerPluginCatalogSummary } from "@/lib/api/appServerTypes"; +import { PLUGIN_CATALOG_CHANGED_EVENT } from "@/lib/api/pluginCatalog"; +import { + cleanupMountedRoots, + flushEffects, + mountHarness, + setupReactActEnvironment, + type MountedRoot, +} from "@/components/workspace/hooks/testUtils"; +import { + useWorkspacePluginCatalogSuggestions, + type UseWorkspacePluginCatalogSuggestionsOptions, + type UseWorkspacePluginCatalogSuggestionsResult, +} from "./useWorkspacePluginCatalogSuggestions"; + +const mountedRoots: MountedRoot[] = []; + +function plugin(enabled = true): AppServerPluginCatalogSummary { + return { + id: "browser", + name: "Browser", + version: "1.0.0", + marketplaceId: "openai-bundled", + contentDigest: "sha256:browser", + description: "Control a browser", + source: "bundled", + sourceUri: "/plugins/browser", + installed: true, + enabled, + installPolicy: "AVAILABLE", + authPolicy: "ON_USE", + availability: "installed", + skillsCount: 1, + mcpServersCount: 0, + appsCount: 0, + hooksCount: 0, + }; +} + +function Harness({ + options, + onReady, +}: { + options: UseWorkspacePluginCatalogSuggestionsOptions; + onReady: (value: UseWorkspacePluginCatalogSuggestionsResult) => void; +}) { + const value = useWorkspacePluginCatalogSuggestions(options); + useEffect(() => onReady(value), [onReady, value]); + return null; +} + +describe("useWorkspacePluginCatalogSuggestions", () => { + let latest: UseWorkspacePluginCatalogSuggestionsResult | null = null; + + beforeEach(() => { + setupReactActEnvironment(); + latest = null; + }); + + afterEach(() => { + cleanupMountedRoots(mountedRoots); + vi.clearAllMocks(); + }); + + it("按需读取 v2 installed catalog,并在 v2 变更事件后刷新", async () => { + const listInstalled = vi + .fn() + .mockResolvedValueOnce({ plugins: [plugin()], generatedAt: "first" }) + .mockResolvedValueOnce({ + plugins: [plugin(false)], + generatedAt: "second", + }); + + mountHarness( + Harness, + { + options: { enabled: true, listInstalled }, + onReady: (value) => { + latest = value; + }, + }, + mountedRoots, + ); + await flushEffects(6); + + expect(latest?.suggestions[0]).toMatchObject({ + pluginId: "browser", + disabled: false, + }); + + act(() => { + window.dispatchEvent(new CustomEvent(PLUGIN_CATALOG_CHANGED_EVENT)); + }); + await flushEffects(6); + + expect(listInstalled).toHaveBeenCalledTimes(2); + expect(latest?.suggestions[0]).toMatchObject({ + pluginId: "browser", + disabled: true, + blockerCodes: ["PLUGIN_DISABLED"], + }); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceRightSurfaceActionRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceRightSurfaceActionRuntime.ts index 7414a57ab..4bb56b7bd 100644 --- a/src/components/agent/chat/workspace/useWorkspaceRightSurfaceActionRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceRightSurfaceActionRuntime.ts @@ -71,6 +71,7 @@ interface UseWorkspaceRightSurfaceActionRuntimeParams { setActivePluginSurfaces: Dispatch< SetStateAction >; + setDismissedPluginSurfaceContainerIds: Dispatch>; setExpertInfoPanelCollapsed: SetBoolean; setHarnessPanelVisible: SetBoolean; setLayoutMode: Dispatch>; @@ -87,7 +88,9 @@ interface WorkspaceRightSurfaceActionRuntime { url: string, title?: string | null, ) => void; - handleSelectPluginSurface: (surface: WorkspacePluginSurfaceDescriptor) => void; + handleSelectPluginSurface: ( + surface: WorkspacePluginSurfaceDescriptor, + ) => void; handleSelectRightSurfaceTab: (kind: WorkspaceRightSurfaceKind) => void; handleToggleExpertInfoPanel: () => void; handleToggleCanvasFromRightSurface: () => void; @@ -131,6 +134,7 @@ export function useWorkspaceRightSurfaceActionRuntime({ setActiveObjectCanvasRightSurfaceCandidate, setActivePluginSurfaceContainerId, setActivePluginSurfaces, + setDismissedPluginSurfaceContainerIds, setExpertInfoPanelCollapsed, setHarnessPanelVisible, setLayoutMode, @@ -155,6 +159,15 @@ export function useWorkspaceRightSurfaceActionRuntime({ if (pendingPluginSurfaces.length === 0) { return; } + const hasNewPluginSurface = pendingPluginSurfaces.some( + (incoming) => + !activePluginSurfaces.some( + (current) => current.containerId === incoming.containerId, + ), + ); + if (!hasNewPluginSurface) { + return; + } setActivePluginSurfaces((current) => mergeWorkspacePluginSurfaceDescriptors(current, pendingPluginSurfaces), @@ -609,6 +622,11 @@ export function useWorkspaceRightSurfaceActionRuntime({ surfaces: pluginSurfaceRightSurfaces, }); setActivePluginSurfaces(result.surfaces); + setDismissedPluginSurfaceContainerIds((current) => + current.includes(surface.containerId) + ? current + : [...current, surface.containerId], + ); setActivePluginSurfaceContainerId(result.activeContainerId); if (result.surfaces.length === 0 && manualRightSurface === "appSurface") { setManualRightSurface(null); @@ -625,6 +643,7 @@ export function useWorkspaceRightSurfaceActionRuntime({ pluginSurfaceRightSurfaces, setActivePluginSurfaceContainerId, setActivePluginSurfaces, + setDismissedPluginSurfaceContainerIds, setManualRightSurface, ], ); diff --git a/src/components/agent/chat/workspace/useWorkspaceRightSurfaceCoordinatorRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceRightSurfaceCoordinatorRuntime.ts index b5a386509..5ed12af43 100644 --- a/src/components/agent/chat/workspace/useWorkspaceRightSurfaceCoordinatorRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceRightSurfaceCoordinatorRuntime.ts @@ -1,5 +1,12 @@ -import type { Dispatch, SetStateAction } from "react"; +import { + useEffect, + useMemo, + useRef, + type Dispatch, + type SetStateAction, +} from "react"; import type { LayoutMode } from "@/lib/workspace/workbenchContract"; +import type { AgentThreadItem } from "@/lib/api/agentProtocol"; import type { BrowserAssistSessionState } from "../types"; import type { WorkspaceFilesSurfaceTarget } from "./WorkspaceFilesSurface"; import type { WorkspaceArticleWorkspace } from "./workspaceArticleWorkspaceModel"; @@ -11,6 +18,13 @@ import { useWorkspaceRightSurfaceDerivedRuntime } from "./useWorkspaceRightSurfa import type { WorkspaceRightSurfaceLocalStateRuntime } from "./useWorkspaceRightSurfaceLocalStateRuntime"; import { useWorkspaceRightSurfacePendingBridgeRuntime } from "./useWorkspaceRightSurfacePendingBridgeRuntime"; import { useWorkspaceRightSurfaceProjectionRuntime } from "./useWorkspaceRightSurfaceProjectionRuntime"; +import { + buildWorkspacePluginSurfacesFromThreadItems, + mergeWorkspacePluginSurfaceDescriptors, + resolveWorkspacePluginSurfaceSessionEpoch, + resolveWorkspacePluginSurfaceThreadId, + type WorkspacePluginSurfaceSessionEpoch, +} from "./workspacePluginSurfaceModel"; interface UseWorkspaceRightSurfaceCoordinatorRuntimeParams { articleEditorRightSurface: WorkspaceArticleWorkspace | null; @@ -37,11 +51,13 @@ interface UseWorkspaceRightSurfaceCoordinatorRuntimeParams { sceneIsSending: boolean; sceneLayoutMode: LayoutMode; sceneSessionId?: string | null; + sceneThreadId?: string | null; sessionId?: string | null; shellRightSurfaceAvailable: boolean; showHarnessToggle: boolean; suppressHomeNavbarUtilityActions: boolean; taskCenterHomeHotpathActive: boolean; + threadItems: readonly AgentThreadItem[]; setExpertInfoPanelCollapsed: (value: boolean) => void; setHarnessPanelVisible: (value: boolean) => void; setLayoutMode: Dispatch>; @@ -70,15 +86,50 @@ export function useWorkspaceRightSurfaceCoordinatorRuntime({ sceneIsSending, sceneLayoutMode, sceneSessionId, + sceneThreadId, sessionId, shellRightSurfaceAvailable, showHarnessToggle, suppressHomeNavbarUtilityActions, taskCenterHomeHotpathActive, + threadItems, setExpertInfoPanelCollapsed, setHarnessPanelVisible, setLayoutMode, }: UseWorkspaceRightSurfaceCoordinatorRuntimeParams) { + const effectiveSessionId = sessionId || sceneSessionId || null; + const effectiveSceneThreadId = resolveWorkspacePluginSurfaceThreadId( + sceneThreadId, + threadItems, + ); + const pluginSurfaceSessionEpochRef = + useRef(null); + const pluginSurfaceSessionEpoch = resolveWorkspacePluginSurfaceSessionEpoch({ + currentSessionId: effectiveSessionId, + currentThreadId: effectiveSceneThreadId, + previousEpoch: pluginSurfaceSessionEpochRef.current, + }); + useEffect(() => { + if (pluginSurfaceSessionEpoch.ready) { + pluginSurfaceSessionEpochRef.current = pluginSurfaceSessionEpoch.epoch; + } + }, [pluginSurfaceSessionEpoch.epoch, pluginSurfaceSessionEpoch.ready]); + const canonicalPluginSurfaces = useMemo( + () => + pluginSurfaceSessionEpoch.ready + ? buildWorkspacePluginSurfacesFromThreadItems( + threadItems, + localState.dismissedPluginSurfaceContainerIds, + pluginSurfaceSessionEpoch.epoch?.threadId, + ) + : [], + [ + localState.dismissedPluginSurfaceContainerIds, + pluginSurfaceSessionEpoch.epoch?.threadId, + pluginSurfaceSessionEpoch.ready, + threadItems, + ], + ); const pendingRuntime = useWorkspaceRightSurfacePendingBridgeRuntime({ bindRightSurfacePendingActions, canvasWorkbenchRootPath, @@ -92,14 +143,40 @@ export function useWorkspaceRightSurfaceCoordinatorRuntime({ sessionId, taskCenterHomeHotpathActive, }); + const mergedIncomingPluginSurfaces = useMemo( + () => + mergeWorkspacePluginSurfaceDescriptors( + pendingRuntime.pendingPluginSurfaces, + canonicalPluginSurfaces, + ), + [canonicalPluginSurfaces, pendingRuntime.pendingPluginSurfaces], + ); + const { + setActivePluginSurfaceContainerId, + setActivePluginSurfaces, + setDismissedPluginSurfaceContainerIds, + setManualRightSurface, + } = localState; + useEffect(() => { + setActivePluginSurfaces([]); + setActivePluginSurfaceContainerId(null); + setDismissedPluginSurfaceContainerIds([]); + setManualRightSurface((current) => + current === "appSurface" ? null : current, + ); + }, [ + effectiveSessionId, + setActivePluginSurfaceContainerId, + setActivePluginSurfaces, + setDismissedPluginSurfaceContainerIds, + setManualRightSurface, + ]); const derivedRuntime = useWorkspaceRightSurfaceDerivedRuntime({ - activeBrowserRightSurfaceIntent: - localState.activeBrowserRightSurfaceIntent, + activeBrowserRightSurfaceIntent: localState.activeBrowserRightSurfaceIntent, activeFilesRightSurfaceTarget: localState.activeFilesRightSurfaceTarget, activeObjectCanvasRightSurfaceCandidate: localState.activeObjectCanvasRightSurfaceCandidate, - activePluginSurfaceContainerId: - localState.activePluginSurfaceContainerId, + activePluginSurfaceContainerId: localState.activePluginSurfaceContainerId, activePluginSurfaces: localState.activePluginSurfaces, browserAssistLaunching, browserAssistSessionRef, @@ -108,7 +185,7 @@ export function useWorkspaceRightSurfaceCoordinatorRuntime({ pendingBrowserRightSurfaceIntent: pendingRuntime.pendingBrowserIntent, pendingFileTarget: pendingRuntime.pendingFileTarget, pendingObjectCanvasCandidate: pendingRuntime.pendingObjectCanvasCandidate, - pendingPluginSurfaces: pendingRuntime.pendingPluginSurfaces, + pendingPluginSurfaces: mergedIncomingPluginSurfaces, preferredServiceSkillResultFileTarget, }); const rightSurfaceHarnessEnabled = @@ -138,10 +215,8 @@ export function useWorkspaceRightSurfaceCoordinatorRuntime({ traceAvailable: rightSurfaceTraceAvailable, }); const actionRuntime = useWorkspaceRightSurfaceActionRuntime({ - activeBrowserRightSurfaceIntent: - localState.activeBrowserRightSurfaceIntent, - activePluginSurfaceContainerId: - localState.activePluginSurfaceContainerId, + activeBrowserRightSurfaceIntent: localState.activeBrowserRightSurfaceIntent, + activePluginSurfaceContainerId: localState.activePluginSurfaceContainerId, activePluginSurfaces: localState.activePluginSurfaces, articleEditorRightSurface, articleEditorRightSurfaceAvailable, @@ -160,7 +235,7 @@ export function useWorkspaceRightSurfaceCoordinatorRuntime({ objectCanvasRightSurfaceCandidate: derivedRuntime.objectCanvasRightSurfaceCandidate, pendingBrowserRightSurfaceIntent: pendingRuntime.pendingBrowserIntent, - pendingPluginSurfaces: pendingRuntime.pendingPluginSurfaces, + pendingPluginSurfaces: mergedIncomingPluginSurfaces, pluginSurfaceRightSurface: derivedRuntime.pluginSurfaceRightSurface, pluginSurfaceRightSurfaceAvailable: derivedRuntime.pluginSurfaceRightSurfaceAvailable, @@ -180,6 +255,8 @@ export function useWorkspaceRightSurfaceCoordinatorRuntime({ setActivePluginSurfaceContainerId: localState.setActivePluginSurfaceContainerId, setActivePluginSurfaces: localState.setActivePluginSurfaces, + setDismissedPluginSurfaceContainerIds: + localState.setDismissedPluginSurfaceContainerIds, setExpertInfoPanelCollapsed, setHarnessPanelVisible, setLayoutMode, @@ -190,8 +267,7 @@ export function useWorkspaceRightSurfaceCoordinatorRuntime({ return { ...derivedRuntime, ...actionRuntime, - activePluginSurfaceContainerId: - localState.activePluginSurfaceContainerId, + activePluginSurfaceContainerId: localState.activePluginSurfaceContainerId, rightSurfaceBrowserTitle: localState.rightSurfaceBrowserTitle, rightSurfaceHarnessEnabled, rightSurfaceLaunchers, diff --git a/src/components/agent/chat/workspace/useWorkspaceRightSurfaceLocalStateRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceRightSurfaceLocalStateRuntime.ts index c5081f604..ff0fba969 100644 --- a/src/components/agent/chat/workspace/useWorkspaceRightSurfaceLocalStateRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceRightSurfaceLocalStateRuntime.ts @@ -27,6 +27,10 @@ export function useWorkspaceRightSurfaceLocalStateRuntime() { >([]); const [activePluginSurfaceContainerId, setActivePluginSurfaceContainerId] = useState(null); + const [ + dismissedPluginSurfaceContainerIds, + setDismissedPluginSurfaceContainerIds, + ] = useState([]); const clearActiveRightSurfaceTargets = useCallback(() => { setManualRightSurface(null); @@ -53,6 +57,7 @@ export function useWorkspaceRightSurfaceLocalStateRuntime() { activePluginSurfaceContainerId, activePluginSurfaces, clearActiveRightSurfaceTargets, + dismissedPluginSurfaceContainerIds, manualRightSurface, openArticleWorkspaceRightSurface, rightSurfaceBrowserTitle, @@ -62,6 +67,7 @@ export function useWorkspaceRightSurfaceLocalStateRuntime() { setActiveObjectCanvasRightSurfaceCandidate, setActivePluginSurfaceContainerId, setActivePluginSurfaces, + setDismissedPluginSurfaceContainerIds, setManualRightSurface, setRightSurfaceBrowserTitle, }; diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts index 42adeae30..172ca3073 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts @@ -2691,6 +2691,9 @@ export function useWorkspaceSendActions({ !sendOptions?.purpose && !hasBoundSkillLaunch && !hasMatchedWorkspaceMentionCommandWithoutAgentTurnRoute && + !sendOptions?.inputMentions?.some((mention) => + mention.path.startsWith("plugin://"), + ) && sourceText.trim().startsWith("@"); if (shouldResolvePluginActivation) { const pluginSessionId = await ensureCommandSessionId(); diff --git a/src/components/agent/chat/workspace/workspaceMcpAppResource.ts b/src/components/agent/chat/workspace/workspaceMcpAppResource.ts new file mode 100644 index 000000000..59819c489 --- /dev/null +++ b/src/components/agent/chat/workspace/workspaceMcpAppResource.ts @@ -0,0 +1,83 @@ +import type { EmbeddedBrowserHtmlParams } from "@/lib/api/embeddedBrowser"; +import type { McpResourceContent } from "@/lib/api/mcp"; + +export type WorkspaceMcpAppResourceErrorCode = + | "invalidMimeType" + | "missingHtml" + | "uriMismatch"; + +export class WorkspaceMcpAppResourceError extends Error { + constructor(readonly code: WorkspaceMcpAppResourceErrorCode) { + super(code); + this.name = "WorkspaceMcpAppResourceError"; + } +} + +export function buildWorkspaceMcpAppHtmlParams({ + content, + expectedUri, + viewId, +}: { + content: McpResourceContent; + expectedUri: string; + viewId: string; +}): EmbeddedBrowserHtmlParams { + if (content.uri !== expectedUri) { + throw new WorkspaceMcpAppResourceError("uriMismatch"); + } + if (!isMcpAppHtmlMimeType(content.mime_type)) { + throw new WorkspaceMcpAppResourceError("invalidMimeType"); + } + if (typeof content.text !== "string" || !content.text.trim()) { + throw new WorkspaceMcpAppResourceError("missingHtml"); + } + return { + viewId, + csp: readMcpAppCsp(content.meta), + html: content.text, + source: "mcpApp", + sourceUri: expectedUri, + }; +} + +function isMcpAppHtmlMimeType(value: string | undefined): boolean { + if (!value) { + return false; + } + const [type, ...parameters] = value + .toLowerCase() + .split(";") + .map((part) => part.trim()); + return ( + type === "text/html" && + parameters.some((parameter) => parameter === "profile=mcp-app") + ); +} + +function readMcpAppCsp( + meta: Record | undefined, +): EmbeddedBrowserHtmlParams["csp"] { + const ui = asRecord(meta?.ui); + const csp = asRecord(ui?.csp); + if (!csp) { + return undefined; + } + return { + baseUriDomains: readStringArray(csp.baseUriDomains), + connectDomains: readStringArray(csp.connectDomains), + frameDomains: readStringArray(csp.frameDomains), + resourceDomains: readStringArray(csp.resourceDomains), + }; +} + +function readStringArray(value: unknown): string[] | undefined { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : undefined; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} diff --git a/src/components/agent/chat/workspace/workspaceMcpAppResource.unit.test.ts b/src/components/agent/chat/workspace/workspaceMcpAppResource.unit.test.ts new file mode 100644 index 000000000..74a5824bf --- /dev/null +++ b/src/components/agent/chat/workspace/workspaceMcpAppResource.unit.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + buildWorkspaceMcpAppHtmlParams, + WorkspaceMcpAppResourceError, +} from "./workspaceMcpAppResource"; + +describe("workspaceMcpAppResource", () => { + it("将标准 MCP App HTML 与 CSP 投影为受控宿主参数", () => { + expect( + buildWorkspaceMcpAppHtmlParams({ + content: { + uri: "ui://plugin/report.html", + mime_type: "text/html;profile=mcp-app", + text: "
report
", + meta: { + ui: { + csp: { + connectDomains: ["https://api.example.com"], + resourceDomains: ["https://cdn.example.com"], + }, + }, + }, + }, + expectedUri: "ui://plugin/report.html", + viewId: "plugin-item-1", + }), + ).toEqual({ + viewId: "plugin-item-1", + csp: { + baseUriDomains: undefined, + connectDomains: ["https://api.example.com"], + frameDomains: undefined, + resourceDomains: ["https://cdn.example.com"], + }, + html: "
report
", + source: "mcpApp", + sourceUri: "ui://plugin/report.html", + }); + }); + + it.each([ + [ + "uriMismatch", + { uri: "ui://other/report.html", mime_type: "text/html;profile=mcp-app", text: "

x

" }, + ], + [ + "invalidMimeType", + { uri: "ui://plugin/report.html", mime_type: "text/html", text: "

x

" }, + ], + [ + "missingHtml", + { uri: "ui://plugin/report.html", mime_type: "text/html;profile=mcp-app" }, + ], + ] as const)("拒绝不符合 MCP Apps 合同的资源: %s", (code, content) => { + expect(() => + buildWorkspaceMcpAppHtmlParams({ + content, + expectedUri: "ui://plugin/report.html", + viewId: "plugin-item-1", + }), + ).toThrow(expect.objectContaining>({ code })); + }); +}); diff --git a/src/components/agent/chat/workspace/workspacePluginCatalogSuggestions.ts b/src/components/agent/chat/workspace/workspacePluginCatalogSuggestions.ts new file mode 100644 index 000000000..6b75ecc18 --- /dev/null +++ b/src/components/agent/chat/workspace/workspacePluginCatalogSuggestions.ts @@ -0,0 +1,18 @@ +import type { AppServerPluginCatalogSummary } from "@/lib/api/appServerTypes"; +import type { InputbarPluginCapability } from "../components/Inputbar/pluginInputCapability"; + +const PLUGIN_DISABLED_BLOCKER = "PLUGIN_DISABLED"; + +export function buildWorkspacePluginCatalogSuggestions( + plugins: readonly AppServerPluginCatalogSummary[], +): InputbarPluginCapability[] { + return plugins + .filter((plugin) => plugin.installed) + .map((plugin) => ({ + pluginId: plugin.id, + displayName: plugin.name.trim() || plugin.id, + description: plugin.description.trim() || plugin.id, + disabled: !plugin.enabled, + blockerCodes: plugin.enabled ? [] : [PLUGIN_DISABLED_BLOCKER], + })); +} diff --git a/src/components/agent/chat/workspace/workspacePluginCatalogSuggestions.unit.test.ts b/src/components/agent/chat/workspace/workspacePluginCatalogSuggestions.unit.test.ts new file mode 100644 index 000000000..b9f65396a --- /dev/null +++ b/src/components/agent/chat/workspace/workspacePluginCatalogSuggestions.unit.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import type { AppServerPluginCatalogSummary } from "@/lib/api/appServerTypes"; +import { buildWorkspacePluginCatalogSuggestions } from "./workspacePluginCatalogSuggestions"; + +function plugin( + overrides: Partial = {}, +): AppServerPluginCatalogSummary { + return { + id: "browser", + name: "Browser", + version: "1.0.0", + marketplaceId: "openai-bundled", + contentDigest: "sha256:browser", + description: "Control a browser", + source: "bundled", + sourceUri: "/plugins/browser", + installed: true, + enabled: true, + installPolicy: "AVAILABLE", + authPolicy: "ON_USE", + availability: "installed", + skillsCount: 1, + mcpServersCount: 0, + appsCount: 0, + hooksCount: 0, + ...overrides, + }; +} + +describe("workspacePluginCatalogSuggestions", () => { + it("只从 v2 installed summary 投影稳定 Plugin 候选", () => { + expect( + buildWorkspacePluginCatalogSuggestions([ + plugin(), + plugin({ id: "available", name: "Available", installed: false }), + plugin({ id: "disabled", name: "Disabled", enabled: false }), + ]), + ).toEqual([ + { + pluginId: "browser", + displayName: "Browser", + description: "Control a browser", + disabled: false, + blockerCodes: [], + }, + { + pluginId: "disabled", + displayName: "Disabled", + description: "Control a browser", + disabled: true, + blockerCodes: ["PLUGIN_DISABLED"], + }, + ]); + }); +}); diff --git a/src/components/agent/chat/workspace/workspacePluginSurfaceModel.ts b/src/components/agent/chat/workspace/workspacePluginSurfaceModel.ts index 7bed76154..d1aae23a0 100644 --- a/src/components/agent/chat/workspace/workspacePluginSurfaceModel.ts +++ b/src/components/agent/chat/workspace/workspacePluginSurfaceModel.ts @@ -1,4 +1,5 @@ import type { WorkspaceRightSurfacePendingRequest } from "@/lib/api/workspaceRightSurface"; +import type { AgentThreadItem } from "@/lib/api/agentProtocol"; export type WorkspacePluginSurfaceStrategy = | "controlledBrowserWindow" @@ -7,19 +8,123 @@ export type WorkspacePluginSurfaceStrategy = export interface WorkspacePluginSurfaceDescriptor { appId: string; title: string; - entryUrl: string; + entryUrl?: string; + mcpApp?: { + resourceUri: string; + serverName: string; + toolItemId: string; + }; containerId: string; activeStrategy: WorkspacePluginSurfaceStrategy; supportedStrategies: WorkspacePluginSurfaceStrategy[]; sourceRequestId?: string; } +export interface WorkspacePluginSurfaceSessionEpoch { + sessionId: string; + threadId: string; +} + +export function resolveWorkspacePluginSurfaceThreadId( + currentThreadId: string | null | undefined, + threadItems: readonly AgentThreadItem[], +): string | null { + const explicitThreadId = normalizeKey(currentThreadId); + if (explicitThreadId) { + return explicitThreadId; + } + + const itemThreadIds = new Set( + threadItems.map((item) => normalizeKey(item.thread_id)).filter(Boolean), + ); + return itemThreadIds.size === 1 ? [...itemThreadIds][0] ?? null : null; +} + +export function resolveWorkspacePluginSurfaceSessionEpoch({ + currentSessionId, + currentThreadId, + previousEpoch, +}: { + currentSessionId?: string | null; + currentThreadId?: string | null; + previousEpoch?: WorkspacePluginSurfaceSessionEpoch | null; +}): { + epoch: WorkspacePluginSurfaceSessionEpoch | null; + ready: boolean; +} { + const sessionId = normalizeKey(currentSessionId); + const threadId = normalizeKey(currentThreadId); + if (!sessionId || !threadId) { + return { epoch: previousEpoch ?? null, ready: false }; + } + if ( + previousEpoch && + previousEpoch.sessionId !== sessionId && + previousEpoch.threadId === threadId + ) { + return { epoch: previousEpoch, ready: false }; + } + return { + epoch: { sessionId, threadId }, + ready: true, + }; +} + +export function buildWorkspacePluginSurfacesFromThreadItems( + threadItems: readonly AgentThreadItem[], + dismissedContainerIds: readonly string[] = [], + threadId?: string | null, +): WorkspacePluginSurfaceDescriptor[] { + const dismissed = new Set( + dismissedContainerIds.map(normalizeKey).filter(Boolean), + ); + const normalizedThreadId = normalizeKey(threadId); + const next: WorkspacePluginSurfaceDescriptor[] = []; + for (const item of threadItems) { + if ( + normalizedThreadId && + normalizeKey(item.thread_id) !== normalizedThreadId + ) { + continue; + } + if (item.type !== "tool_call" || item.status !== "completed") { + continue; + } + const metadata = asRecord(item.metadata); + if (firstString(metadata?.canonical_type) !== "mcpToolCall") { + continue; + } + const pluginId = firstString(metadata?.plugin_id); + const resourceUri = firstString(metadata?.mcp_app_resource_uri); + const serverName = firstString(metadata?.server); + if (!pluginId || !resourceUri || !serverName || !isMcpAppUri(resourceUri)) { + continue; + } + const containerId = `mcp-app-${item.id}`; + if (dismissed.has(normalizeKey(containerId))) { + continue; + } + upsertWorkspacePluginSurfaceDescriptor(next, { + appId: pluginId, + title: pluginId, + containerId, + activeStrategy: "webContentsView", + supportedStrategies: ["webContentsView"], + mcpApp: { + resourceUri, + serverName, + toolItemId: item.id, + }, + }); + } + return next; +} + export function buildWorkspacePluginSurfaceFromPendingRequests( pendingRequests: readonly WorkspaceRightSurfacePendingRequest[], ): WorkspacePluginSurfaceDescriptor | null { return ( - buildWorkspacePluginSurfacesFromPendingRequests(pendingRequests)[0] ?? - null + buildWorkspacePluginSurfacesFromPendingRequests(pendingRequests)[0] ?? null ); } @@ -265,3 +370,11 @@ function normalizeKey(value: string | null | undefined): string | null { const normalized = value?.trim(); return normalized ? normalized : null; } + +function isMcpAppUri(value: string): boolean { + try { + return new URL(value).protocol === "ui:"; + } catch { + return false; + } +} diff --git a/src/components/agent/chat/workspace/workspacePluginSurfaceModel.unit.test.ts b/src/components/agent/chat/workspace/workspacePluginSurfaceModel.unit.test.ts index b2b699514..75e8a23ff 100644 --- a/src/components/agent/chat/workspace/workspacePluginSurfaceModel.unit.test.ts +++ b/src/components/agent/chat/workspace/workspacePluginSurfaceModel.unit.test.ts @@ -4,9 +4,12 @@ import { buildWorkspacePluginSurfaceFromPendingRequest, buildWorkspacePluginSurfaceFromPendingRequests, buildWorkspacePluginSurfacesFromPendingRequests, + buildWorkspacePluginSurfacesFromThreadItems, closeWorkspacePluginSurfaceDescriptor, mergeWorkspacePluginSurfaceDescriptors, resolveWorkspacePluginSurfaceActiveContainerId, + resolveWorkspacePluginSurfaceSessionEpoch, + resolveWorkspacePluginSurfaceThreadId, selectWorkspacePluginSurfaceDescriptor, } from "./workspacePluginSurfaceModel"; @@ -24,6 +27,193 @@ const basePending: WorkspaceRightSurfacePendingRequest = { }; describe("workspacePluginSurfaceModel", () => { + it("应从 canonical MCP tool item 生成可恢复的 MCP App surface", () => { + const surfaces = buildWorkspacePluginSurfacesFromThreadItems([ + { + id: "item-mcp-app-1", + thread_id: "thread-1", + turn_id: "turn-1", + sequence: 3, + status: "completed", + started_at: "2026-08-04T00:00:00.000Z", + updated_at: "2026-08-04T00:00:01.000Z", + completed_at: "2026-08-04T00:00:01.000Z", + type: "tool_call", + tool_name: "mcp__plugin__demo__report", + success: true, + metadata: { + canonical_type: "mcpToolCall", + server: "plugin__demo__server", + plugin_id: "demo-plugin", + mcp_app_resource_uri: "ui://demo/report.html", + }, + }, + ]); + + expect(surfaces).toEqual([ + { + appId: "demo-plugin", + title: "demo-plugin", + containerId: "mcp-app-item-mcp-app-1", + activeStrategy: "webContentsView", + supportedStrategies: ["webContentsView"], + mcpApp: { + resourceUri: "ui://demo/report.html", + serverName: "plugin__demo__server", + toolItemId: "item-mcp-app-1", + }, + }, + ]); + expect( + buildWorkspacePluginSurfacesFromThreadItems( + [ + { + id: "item-mcp-app-1", + thread_id: "thread-1", + turn_id: "turn-1", + sequence: 3, + status: "completed", + started_at: "2026-08-04T00:00:00.000Z", + updated_at: "2026-08-04T00:00:01.000Z", + type: "tool_call", + tool_name: "mcp__plugin__demo__report", + metadata: { + canonical_type: "mcpToolCall", + server: "plugin__demo__server", + plugin_id: "demo-plugin", + mcp_app_resource_uri: "ui://demo/report.html", + }, + }, + ], + ["mcp-app-item-mcp-app-1"], + ), + ).toEqual([]); + }); + + it("应按 canonical thread identity 过滤旧会话 item", () => { + const item = { + id: "item-mcp-app-1", + thread_id: "thread-old", + turn_id: "turn-1", + sequence: 3, + status: "completed" as const, + started_at: "2026-08-04T00:00:00.000Z", + updated_at: "2026-08-04T00:00:01.000Z", + type: "tool_call" as const, + tool_name: "mcp__plugin__demo__report", + metadata: { + canonical_type: "mcpToolCall", + server: "plugin__demo__server", + plugin_id: "demo-plugin", + mcp_app_resource_uri: "ui://demo/report.html", + }, + }; + + expect( + buildWorkspacePluginSurfacesFromThreadItems([item], [], "thread-new"), + ).toEqual([]); + expect( + buildWorkspacePluginSurfacesFromThreadItems([item], [], "thread-old")[0] + ?.containerId, + ).toBe("mcp-app-item-mcp-app-1"); + }); + + it("session 切换期间应屏蔽旧 thread,目标 thread 到达后恢复同一 identity", () => { + const initial = resolveWorkspacePluginSurfaceSessionEpoch({ + currentSessionId: "session-old", + currentThreadId: "thread-old", + previousEpoch: null, + }); + const switching = resolveWorkspacePluginSurfaceSessionEpoch({ + currentSessionId: "session-new", + currentThreadId: "thread-old", + previousEpoch: initial.epoch, + }); + const restored = resolveWorkspacePluginSurfaceSessionEpoch({ + currentSessionId: "session-new", + currentThreadId: "thread-new", + previousEpoch: initial.epoch, + }); + + expect(initial).toEqual({ + epoch: { sessionId: "session-old", threadId: "thread-old" }, + ready: true, + }); + expect(switching).toEqual({ + epoch: { sessionId: "session-old", threadId: "thread-old" }, + ready: false, + }); + expect(restored).toEqual({ + epoch: { sessionId: "session-new", threadId: "thread-new" }, + ready: true, + }); + }); + + it("read model 延迟时应从同一 scene items 恢复 thread identity", () => { + const item = { + id: "item-mcp-app-1", + thread_id: "thread-scene", + turn_id: "turn-1", + sequence: 1, + status: "completed" as const, + started_at: "2026-08-04T00:00:00.000Z", + updated_at: "2026-08-04T00:00:01.000Z", + type: "tool_call" as const, + tool_name: "mcp__plugin__demo__report", + }; + + expect(resolveWorkspacePluginSurfaceThreadId(null, [item])).toBe( + "thread-scene", + ); + expect( + resolveWorkspacePluginSurfaceThreadId("thread-read-model", [item]), + ).toBe("thread-read-model"); + expect( + resolveWorkspacePluginSurfaceThreadId(null, [ + item, + { ...item, id: "item-2", thread_id: "thread-other" }, + ]), + ).toBeNull(); + }); + + it("冷恢复应重建同一 surface identity,用户关闭后不应再次投影", () => { + const item = { + id: "item-mcp-app-stable", + thread_id: "thread-stable", + turn_id: "turn-stable", + sequence: 1, + status: "completed" as const, + started_at: "2026-08-04T00:00:00.000Z", + updated_at: "2026-08-04T00:00:01.000Z", + type: "tool_call" as const, + tool_name: "mcp__plugin__demo__report", + metadata: { + canonical_type: "mcpToolCall", + server: "plugin__demo__server", + plugin_id: "demo-plugin", + mcp_app_resource_uri: "ui://demo/report.html", + }, + }; + const first = buildWorkspacePluginSurfacesFromThreadItems( + [item], + [], + "thread-stable", + ); + const restored = buildWorkspacePluginSurfacesFromThreadItems( + [item], + [], + "thread-stable", + ); + + expect(restored).toEqual(first); + expect( + buildWorkspacePluginSurfacesFromThreadItems( + [item], + [first[0]?.containerId ?? ""], + "thread-stable", + ), + ).toEqual([]); + }); it("应从 Right Surface pending metadata 水合 Plugin Surface descriptor", () => { expect( buildWorkspacePluginSurfaceFromPendingRequests([ diff --git a/src/features/plugin/ui/PluginAppCenterList.tsx b/src/features/plugin/ui/PluginAppCenterList.tsx index 726be9b2e..7327c8a0a 100644 --- a/src/features/plugin/ui/PluginAppCenterList.tsx +++ b/src/features/plugin/ui/PluginAppCenterList.tsx @@ -1,7 +1,10 @@ +import { useEffect, useRef, useState } from "react"; import { ChevronLeft, ChevronRight, + ChevronDown, FolderOpen, + MoreHorizontal, RefreshCw, Search, ShieldCheck, @@ -119,16 +122,42 @@ export function PluginAppCenterList({ onTogglePublishWorkbench: () => void; onToggleReleaseReviewWorkbench: () => void; }) { + const [moreActionsOpen, setMoreActionsOpen] = useState(false); + const moreActionsRef = useRef(null); + + useEffect(() => { + if (!moreActionsOpen) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + if (!moreActionsRef.current?.contains(event.target as Node)) { + setMoreActionsOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setMoreActionsOpen(false); + } + }; + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [moreActionsOpen]); + + const closeMoreActions = () => setMoreActionsOpen(false); + return ( <> -
+

{t("plugin.apps.center.title")}

-

- {t("plugin.apps.center.description")} -

{issueCount > 0 ? (

) : null}

-
-
diff --git a/src/features/plugin/ui/PluginCatalogPage.test.tsx b/src/features/plugin/ui/PluginCatalogPage.test.tsx new file mode 100644 index 000000000..754967661 --- /dev/null +++ b/src/features/plugin/ui/PluginCatalogPage.test.tsx @@ -0,0 +1,200 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { changeLimeLocale } from "@/i18n/createI18n"; +import type { AppServerPluginCatalogSummary } from "@/lib/api/appServerTypes"; +import { PluginCatalogPage } from "./PluginCatalogPage"; + +const mocks = vi.hoisted(() => ({ + installPluginCatalog: vi.fn(), + listPluginCatalog: vi.fn(), + readPluginCatalog: vi.fn(), + selectPluginCatalogSource: vi.fn(), + setPluginCatalogEnabled: vi.fn(), + uninstallPluginCatalog: vi.fn(), + toastSuccess: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => mocks.toastSuccess(...args), + }, +})); + +vi.mock("@/lib/api/pluginCatalog", () => ({ + installPluginCatalog: (...args: unknown[]) => + mocks.installPluginCatalog(...args), + listPluginCatalog: (...args: unknown[]) => mocks.listPluginCatalog(...args), + readPluginCatalog: (...args: unknown[]) => mocks.readPluginCatalog(...args), + selectPluginCatalogSource: (...args: unknown[]) => + mocks.selectPluginCatalogSource(...args), + setPluginCatalogEnabled: (...args: unknown[]) => + mocks.setPluginCatalogEnabled(...args), + uninstallPluginCatalog: (...args: unknown[]) => + mocks.uninstallPluginCatalog(...args), +})); + +function summary( + overrides: Partial = {}, +): AppServerPluginCatalogSummary { + return { + authPolicy: "ON_USE", + availability: "installed", + description: "Writing and research tools", + enabled: true, + hooksCount: 1, + id: "writer-plugin", + marketplaceId: "personal", + contentDigest: "sha256:test", + installPolicy: "AVAILABLE", + installed: true, + localVersion: "1.2.3", + mcpServersCount: 1, + name: "Writer Plugin", + skillsCount: 1, + source: "local", + sourceUri: "/tmp/writer-plugin", + version: "1.2.3", + ...overrides, + }; +} + +const mounted: Array<{ container: HTMLDivElement; root: Root }> = []; + +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function renderPage() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + await flush(); + mounted.push({ container, root }); + return container; +} + +async function click(element: Element | null) { + expect(element).not.toBeNull(); + await act(async () => { + element?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + await flush(); +} + +describe("PluginCatalogPage", () => { + beforeEach(async () => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + await changeLimeLocale("zh-CN"); + vi.clearAllMocks(); + mocks.listPluginCatalog.mockResolvedValue({ + generatedAt: "2026-08-04T00:00:00Z", + plugins: [summary()], + }); + mocks.readPluginCatalog.mockResolvedValue({ + plugin: { + summary: summary(), + skills: [ + { + id: "article-writing", + name: "Article Writing", + description: "", + requiresAuth: false, + }, + ], + mcpServers: [], + apps: [], + hooks: [], + uiResources: [], + }, + }); + mocks.setPluginCatalogEnabled.mockResolvedValue({ + plugin: summary({ enabled: false }), + }); + }); + + afterEach(async () => { + while (mounted.length) { + const entry = mounted.pop(); + if (!entry) { + continue; + } + await act(async () => entry.root.unmount()); + entry.container.remove(); + } + }); + + it("从 v2 catalog 展示安装状态并读取详情", async () => { + const container = await renderPage(); + + expect(container.textContent).toContain("插件中心"); + expect( + container.querySelector('[data-testid="plugin-v2-card-writer-plugin"]'), + ).not.toBeNull(); + expect(mocks.listPluginCatalog).toHaveBeenCalledWith(); + expect(mocks.readPluginCatalog).toHaveBeenCalledWith({ + pluginId: "writer-plugin", + }); + expect(container.textContent).toContain("Article Writing"); + + await click( + container.querySelector( + '[data-testid="plugin-v2-actions-writer-plugin"]', + ), + ); + await click( + container.querySelector('[data-testid="plugin-v2-toggle-writer-plugin"]'), + ); + expect(mocks.setPluginCatalogEnabled).toHaveBeenCalledWith({ + pluginId: "writer-plugin", + enabled: false, + }); + }); + + it("通过原生目录选择和 App Server review 安装本地插件", async () => { + const available = summary({ installed: false, enabled: false }); + mocks.listPluginCatalog.mockImplementation( + async (params?: { marketplacePaths?: string[] }) => + params?.marketplacePaths + ? { generatedAt: "now", plugins: [available] } + : { generatedAt: "now", plugins: [] }, + ); + mocks.selectPluginCatalogSource.mockResolvedValue("/tmp/marketplace"); + mocks.installPluginCatalog.mockResolvedValue({ + plugin: summary(), + }); + + const container = await renderPage(); + await click( + container.querySelector('[data-testid="plugin-v2-install-local"]'), + ); + + expect(mocks.listPluginCatalog).toHaveBeenLastCalledWith({ + marketplacePaths: ["/tmp/marketplace"], + }); + expect( + document.body.querySelector('[data-testid="plugin-v2-install-review"]'), + ).not.toBeNull(); + + await click( + document.body.querySelector('[data-testid="plugin-v2-confirm-install"]'), + ); + expect(mocks.installPluginCatalog).toHaveBeenCalledWith({ + sourcePath: "/tmp/writer-plugin", + marketplaceId: "personal", + source: "local", + expectedDigest: "sha256:test", + }); + }); +}); diff --git a/src/features/plugin/ui/PluginCatalogPage.tsx b/src/features/plugin/ui/PluginCatalogPage.tsx new file mode 100644 index 000000000..f59ed3e46 --- /dev/null +++ b/src/features/plugin/ui/PluginCatalogPage.tsx @@ -0,0 +1,916 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Check, + ChevronDown, + CircleAlert, + ExternalLink, + FolderOpen, + LoaderCircle, + MoreHorizontal, + PackageCheck, + RefreshCw, + Search, + ShieldCheck, + SlidersHorizontal, + Sparkles, + Unplug, + X, +} from "lucide-react"; +import { toast } from "sonner"; +import type { + AppServerPluginCatalogDetail, + AppServerPluginCatalogSummary, +} from "@/lib/api/appServerTypes"; +import { + installPluginCatalog, + listPluginCatalog, + readPluginCatalog, + selectPluginCatalogSource, + setPluginCatalogEnabled, + uninstallPluginCatalog, +} from "@/lib/api/pluginCatalog"; +import type { Page, PageParams, PluginsPageParams } from "@/types/page"; +import type { PluginRightSurfaceLaunchTarget } from "./pluginRightSurfaceLaunch"; +import { + detailCapabilityCount, + filterPluginCatalog, + listPluginCatalogSources, + mergePluginCatalogSummary, + type PluginCatalogView, +} from "./PluginCatalogPageViewModel"; + +interface InstallCandidate { + sourcePath: string; + summary: AppServerPluginCatalogSummary; +} + +function sourceLabel(source: string, t: (key: string) => string): string { + const key = `plugin.catalog.v2.source.${source}`; + const translated = t(key); + return translated === key ? source : translated; +} + +function pathLeaf(path: string): string { + const segments = path.split(/[\\/]/).filter(Boolean); + return segments.at(-1) ?? path; +} + +function formatCapabilityCount( + count: number, + t: (key: string, options?: Record) => string, +): string { + return t("plugin.catalog.v2.capabilityCount", { count }); +} + +export function PluginCatalogPage({ + pageParams, +}: { + onNavigate?: (page: Page, params?: PageParams) => void; + pageParams?: PluginsPageParams; + rightSurfaceTarget?: PluginRightSurfaceLaunchTarget | null; + rightSurfaceTargets?: PluginRightSurfaceLaunchTarget[] | null; +}) { + const { t } = useTranslation("agent"); + const [plugins, setPlugins] = useState([]); + const [selectedId, setSelectedId] = useState( + pageParams?.selectedPluginId ?? null, + ); + const [detail, setDetail] = useState( + null, + ); + const [view, setView] = useState( + pageParams?.statusFilter === "installed" ? "installed" : "all", + ); + const [query, setQuery] = useState(pageParams?.query ?? ""); + const [source, setSource] = useState("all"); + const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + const [actionMenuId, setActionMenuId] = useState(null); + const [installCandidates, setInstallCandidates] = useState< + InstallCandidate[] + >([]); + const [selectedInstallId, setSelectedInstallId] = useState( + null, + ); + const [uninstallTarget, setUninstallTarget] = + useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await listPluginCatalog(); + setPlugins(response.plugins); + setSelectedId((current) => { + if ( + current && + response.plugins.some((plugin) => plugin.id === current) + ) { + return current; + } + return response.plugins[0]?.id ?? null; + }); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + setError(message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + if (!selectedId) { + setDetail(null); + return; + } + const selected = plugins.find((plugin) => plugin.id === selectedId); + if (!selected?.installed) { + setDetail(null); + return; + } + let cancelled = false; + void readPluginCatalog({ pluginId: selectedId }) + .then((response) => { + if (!cancelled) { + setDetail(response.plugin); + } + }) + .catch((cause) => { + if (!cancelled) { + setDetail(null); + setError(cause instanceof Error ? cause.message : String(cause)); + } + }); + return () => { + cancelled = true; + }; + }, [plugins, selectedId]); + + const filteredPlugins = useMemo( + () => filterPluginCatalog(plugins, { query, source, view }), + [plugins, query, source, view], + ); + const sources = useMemo(() => listPluginCatalogSources(plugins), [plugins]); + const selectedSummary = + plugins.find((plugin) => plugin.id === selectedId) ?? null; + const selectedInstallCandidate = + installCandidates.find( + (candidate) => candidate.summary.id === selectedInstallId, + ) ?? + installCandidates[0] ?? + null; + + const handleInstallFromPath = useCallback(async () => { + try { + const sourcePath = await selectPluginCatalogSource({ + title: t("plugin.catalog.v2.selectSource"), + }); + if (!sourcePath) { + return; + } + const response = await listPluginCatalog({ + marketplacePaths: [sourcePath], + }); + const candidates = response.plugins.map((summary) => ({ + summary, + sourcePath: summary.sourceUri, + })); + if (!candidates.length) { + throw new Error(t("plugin.catalog.v2.noManifest")); + } + setInstallCandidates(candidates); + setSelectedInstallId(candidates[0].summary.id); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + }, [t]); + + const handleInstall = useCallback(async () => { + if (!selectedInstallCandidate) { + return; + } + const pluginId = selectedInstallCandidate.summary.id; + setBusyId(pluginId); + try { + const response = await installPluginCatalog({ + sourcePath: selectedInstallCandidate.sourcePath, + marketplaceId: selectedInstallCandidate.summary.marketplaceId, + source: selectedInstallCandidate.summary.source, + expectedDigest: selectedInstallCandidate.summary.contentDigest, + }); + setPlugins((current) => + mergePluginCatalogSummary(current, response.plugin), + ); + setSelectedId(response.plugin.id); + setInstallCandidates([]); + setSelectedInstallId(null); + toast.success(t("plugin.catalog.v2.installSuccess")); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusyId(null); + } + }, [selectedInstallCandidate, t]); + + const handleToggle = useCallback( + async (plugin: AppServerPluginCatalogSummary) => { + setBusyId(plugin.id); + setActionMenuId(null); + try { + const response = await setPluginCatalogEnabled({ + pluginId: plugin.id, + enabled: !plugin.enabled, + }); + setPlugins((current) => + mergePluginCatalogSummary(current, response.plugin), + ); + if (detail?.summary.id === plugin.id) { + setDetail((current) => + current ? { ...current, summary: response.plugin } : current, + ); + } + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusyId(null); + } + }, + [detail], + ); + + const handleUninstall = useCallback(async () => { + if (!uninstallTarget) { + return; + } + const pluginId = uninstallTarget.id; + setBusyId(pluginId); + try { + const response = await uninstallPluginCatalog({ pluginId }); + if (response.uninstalled) { + setPlugins((current) => + current.filter((plugin) => plugin.id !== pluginId), + ); + setSelectedId((current) => (current === pluginId ? null : current)); + setDetail(null); + toast.success(t("plugin.catalog.v2.uninstallSuccess")); + } + setUninstallTarget(null); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusyId(null); + } + }, [t, uninstallTarget]); + + const clearError = () => setError(null); + + return ( +
+
+
+
+
+ + {t("plugin.catalog.v2.eyebrow")} +
+

+ {t("plugin.catalog.v2.title")} +

+

+ {t("plugin.catalog.v2.description")} +

+
+ +
+ +
+
+ {(["all", "installed"] as const).map((nextView) => ( + + ))} +
+
+ + + +
+
+ + {error ? ( +
+
+ + {error} +
+ +
+ ) : null} + + {loading ? ( +
+ + {t("plugin.catalog.v2.loading")} +
+ ) : filteredPlugins.length === 0 ? ( +
+ +

+ {plugins.length === 0 + ? t("plugin.catalog.v2.empty.noPlugins") + : t("plugin.catalog.v2.empty.noMatches")} +

+

+ {t("plugin.catalog.v2.empty.description")} +

+
+ ) : ( +
+
+ {filteredPlugins.map((plugin) => { + const isSelected = selectedId === plugin.id; + const isBusy = busyId === plugin.id; + return ( +
+
+ + {plugin.installed ? ( +
+ + {actionMenuId === plugin.id ? ( +
+ + +
+ ) : null} +
+ ) : null} +
+

+ {plugin.description || + t("plugin.catalog.v2.descriptionFallback")} +

+
+ + {sourceLabel(plugin.source, t)} + + {plugin.installed ? ( + + {plugin.enabled + ? t("plugin.catalog.v2.status.enabled") + : t("plugin.catalog.v2.status.disabled")} + + ) : null} +
+
+ + {t("plugin.catalog.v2.metric.skills", { + count: plugin.skillsCount, + })} + + + {t("plugin.catalog.v2.metric.mcp", { + count: plugin.mcpServersCount, + })} + + + {t("plugin.catalog.v2.metric.hooks", { + count: plugin.hooksCount, + })} + +
+
+ + {plugin.authPolicy === "ON_USE" + ? t("plugin.catalog.v2.auth.onUse") + : plugin.authPolicy} + + {plugin.installed ? ( + + ) : ( + + )} +
+
+ ); + })} +
+ + setSelectedId(null)} + /> +
+ )} +
+ + {installCandidates.length > 0 ? ( +
+
+
+
+

+ {t("plugin.catalog.v2.review.eyebrow")} +

+

+ {t("plugin.catalog.v2.review.title")} +

+
+ +
+ {installCandidates.length > 1 ? ( + + ) : null} + {selectedInstallCandidate ? ( +
+
+
+

+ {selectedInstallCandidate.summary.name} +

+

+ {selectedInstallCandidate.summary.id} ·{" "} + {t("plugin.catalog.v2.version", { + version: selectedInstallCandidate.summary.version, + })} +

+
+ +
+

+ {selectedInstallCandidate.summary.description || + t("plugin.catalog.v2.descriptionFallback")} +

+
+ + {t("plugin.catalog.v2.metric.skills", { + count: selectedInstallCandidate.summary.skillsCount, + })} + + + {t("plugin.catalog.v2.metric.mcp", { + count: selectedInstallCandidate.summary.mcpServersCount, + })} + + + {t("plugin.catalog.v2.metric.hooks", { + count: selectedInstallCandidate.summary.hooksCount, + })} + +
+

+ {t("plugin.catalog.v2.review.source", { + name: pathLeaf(selectedInstallCandidate.sourcePath), + })} +

+
+ ) : null} +
+ + +
+
+
+ ) : null} + + {uninstallTarget ? ( +
+
+

+ {t("plugin.catalog.v2.uninstallTitle", { + name: uninstallTarget.name, + })} +

+

+ {t("plugin.catalog.v2.uninstallDescription")} +

+
+ + +
+
+
+ ) : null} +
+ ); +} + +function PluginCatalogDetailPanel({ + summary, + detail, + t, + onClose, +}: { + summary: AppServerPluginCatalogSummary | null; + detail: AppServerPluginCatalogDetail | null; + t: (key: string, options?: Record) => string; + onClose: () => void; +}) { + if (!summary) { + return ( + + ); + } + return ( + + ); +} + +function DetailMetric({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function CapabilityGroup({ title, items }: { title: string; items: string[] }) { + if (!items.length) { + return null; + } + return ( +
+

+ {title} +

+
+ {items.map((item) => ( + + {item} + + ))} +
+
+ ); +} diff --git a/src/features/plugin/ui/PluginCatalogPageViewModel.ts b/src/features/plugin/ui/PluginCatalogPageViewModel.ts new file mode 100644 index 000000000..097a4c07b --- /dev/null +++ b/src/features/plugin/ui/PluginCatalogPageViewModel.ts @@ -0,0 +1,62 @@ +import type { + AppServerPluginCatalogDetail, + AppServerPluginCatalogSummary, +} from "@/lib/api/appServerTypes"; + +export type PluginCatalogView = "all" | "installed"; + +export interface PluginCatalogFilterState { + query: string; + source: string; + view: PluginCatalogView; +} + +export function filterPluginCatalog( + plugins: AppServerPluginCatalogSummary[], + filters: PluginCatalogFilterState, +): AppServerPluginCatalogSummary[] { + const query = filters.query.trim().toLocaleLowerCase(); + return plugins.filter((plugin) => { + if (filters.view === "installed" && !plugin.installed) { + return false; + } + if (filters.source !== "all" && plugin.source !== filters.source) { + return false; + } + if (!query) { + return true; + } + return [plugin.id, plugin.name, plugin.description, plugin.source] + .join(" ") + .toLocaleLowerCase() + .includes(query); + }); +} + +export function listPluginCatalogSources( + plugins: AppServerPluginCatalogSummary[], +): string[] { + return Array.from( + new Set(plugins.map((plugin) => plugin.source.trim()).filter(Boolean)), + ).sort((left, right) => left.localeCompare(right)); +} + +export function mergePluginCatalogSummary( + plugins: AppServerPluginCatalogSummary[], + summary: AppServerPluginCatalogSummary, +): AppServerPluginCatalogSummary[] { + const next = plugins.filter((plugin) => plugin.id !== summary.id); + next.push(summary); + return next.sort((left, right) => left.name.localeCompare(right.name)); +} + +export function detailCapabilityCount( + detail: AppServerPluginCatalogDetail, +): number { + return ( + detail.skills.length + + detail.mcpServers.length + + detail.apps.length + + detail.hooks.length + ); +} diff --git a/src/features/plugin/ui/PluginCatalogPageViewModel.unit.test.ts b/src/features/plugin/ui/PluginCatalogPageViewModel.unit.test.ts new file mode 100644 index 000000000..3990fd7bd --- /dev/null +++ b/src/features/plugin/ui/PluginCatalogPageViewModel.unit.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import type { AppServerPluginCatalogSummary } from "@/lib/api/appServerTypes"; +import { + detailCapabilityCount, + filterPluginCatalog, + listPluginCatalogSources, + mergePluginCatalogSummary, +} from "./PluginCatalogPageViewModel"; + +function summary( + id: string, + overrides: Partial = {}, +): AppServerPluginCatalogSummary { + return { + authPolicy: "ON_USE", + availability: "installed", + description: `${id} description`, + enabled: true, + hooksCount: 1, + id, + marketplaceId: "personal", + contentDigest: "sha256:test", + installPolicy: "AVAILABLE", + installed: true, + localVersion: "1.0.0", + mcpServersCount: 1, + name: id, + skillsCount: 1, + source: "local", + sourceUri: `/tmp/${id}`, + version: "1.0.0", + ...overrides, + }; +} + +describe("PluginCatalogPageViewModel", () => { + it("按视图、来源和查询过滤同一 catalog projection", () => { + const plugins = [ + summary("writer", { name: "Writing Tools" }), + summary("browser", { + installed: false, + source: "bundled", + name: "Browser Tools", + }), + ]; + + expect( + filterPluginCatalog(plugins, { + query: "writing", + source: "local", + view: "installed", + }).map((plugin) => plugin.id), + ).toEqual(["writer"]); + expect(listPluginCatalogSources(plugins)).toEqual(["bundled", "local"]); + }); + + it("以 plugin identity 合并状态并统计详情能力", () => { + const updated = summary("writer", { enabled: false }); + expect(mergePluginCatalogSummary([summary("writer")], updated)).toEqual([ + updated, + ]); + expect( + detailCapabilityCount({ + summary: updated, + skills: [ + { id: "skill", name: "Skill", description: "", requiresAuth: false }, + ], + mcpServers: [], + apps: [ + { id: "app", name: "App", description: "", requiresAuth: false }, + ], + hooks: [{ id: "turn", event: "turn" }], + uiResources: [], + }), + ).toBe(3); + }); +}); diff --git a/src/features/plugin/ui/PluginsPage.test.tsx b/src/features/plugin/ui/PluginsPage.test.tsx index ff69911b8..9f9ce9547 100644 --- a/src/features/plugin/ui/PluginsPage.test.tsx +++ b/src/features/plugin/ui/PluginsPage.test.tsx @@ -31,7 +31,11 @@ describe("PluginsPage", () => { await flush(); expect(container.textContent).toContain("plugin.apps.center.title"); - expect(container.textContent).toContain("plugin.apps.center.description"); + expect( + container + .querySelector('[data-testid="plugins-search"]') + ?.closest("header")?.textContent, + ).not.toContain("plugin.apps.center.description"); expect( container .querySelector('[data-testid="plugins-search"]') @@ -144,6 +148,51 @@ describe("PluginsPage", () => { ).toContain("min-h-[188px]"); }); + it("应用中心顶部操作区应避免压缩标题和拆分按钮文案", async () => { + const container = await renderPage(); + await flush(); + + const header = container + .querySelector('[data-testid="plugins-search"]') + ?.closest("header"); + expect(header?.className).toContain("xl:flex-row"); + expect(header?.className).not.toContain("lg:flex-row"); + + const actionGroup = container.querySelector( + '[data-testid="plugins-install-local"]', + )?.parentElement; + expect(actionGroup?.className).toContain("flex-wrap"); + + for (const testId of ["plugins-install-local", "plugins-more-actions"]) { + const button = container.querySelector(`[data-testid="${testId}"]`); + expect(button?.className).toContain("shrink-0"); + expect(button?.className).toContain("whitespace-nowrap"); + } + + await act(async () => { + ( + container.querySelector( + '[data-testid="plugins-more-actions"]', + ) as HTMLButtonElement + )?.click(); + await Promise.resolve(); + }); + await flush(); + + expect( + container.querySelector('[data-testid="plugins-more-actions-menu"]'), + ).not.toBeNull(); + for (const testId of [ + "plugins-open-publish", + "plugins-open-release-review", + "plugins-refresh", + ]) { + expect( + container.querySelector(`[data-testid="${testId}"]`), + ).not.toBeNull(); + } + }); + it("应从应用中心打开内置发布工作台", async () => { const container = await renderPage(); await flush(); @@ -152,6 +201,15 @@ describe("PluginsPage", () => { container.querySelector('[data-testid="plugin-publish-workbench"]'), ).toBeNull(); + await act(async () => { + ( + container.querySelector( + '[data-testid="plugins-more-actions"]', + ) as HTMLButtonElement + )?.click(); + await Promise.resolve(); + }); + await flush(); await act(async () => { ( container.querySelector( @@ -175,6 +233,15 @@ describe("PluginsPage", () => { container.querySelector('[data-testid="plugin-review-workbench"]'), ).toBeNull(); + await act(async () => { + ( + container.querySelector( + '[data-testid="plugins-more-actions"]', + ) as HTMLButtonElement + )?.click(); + await Promise.resolve(); + }); + await flush(); await act(async () => { ( container.querySelector( diff --git a/src/i18n/__tests__/loadNamespace.test.ts b/src/i18n/__tests__/loadNamespace.test.ts index 0590a190c..325bdc6bf 100644 --- a/src/i18n/__tests__/loadNamespace.test.ts +++ b/src/i18n/__tests__/loadNamespace.test.ts @@ -188,9 +188,7 @@ describe("i18n namespace loader", () => { "agentChat.messageList.history.windowEntrySummaryRestored", "agentChat.messageList.history.persistedEntrySummary", "agentChat.messageList.taskCenterEmpty.title", - "agentChat.navbar.appSwitcher.finder", "agentChat.navbar.appSwitcher.open", - "agentChat.navbar.appSwitcher.terminal", "agentChat.navbar.appSwitcher.toast.noProjectRoot", "agentChat.navbar.appSwitcher.toast.openFailed", "agentChat.navbar.environment.branchFallback", diff --git a/src/i18n/resources/en-US/agent.json b/src/i18n/resources/en-US/agent.json index 2793ea752..08e1e2c02 100644 --- a/src/i18n/resources/en-US/agent.json +++ b/src/i18n/resources/en-US/agent.json @@ -51,7 +51,6 @@ "plugin.apps.center.action.open": "Open App", "plugin.apps.center.action.update": "Update", "plugin.apps.center.action.updateOneClick": "Update Now", - "plugin.apps.center.description": "Manage apps that open inside Lime, with clear Cloud and Local sources.", "plugin.apps.center.descriptionFallback": "No app description yet.", "plugin.apps.center.detail.appId": "App ID", "plugin.apps.center.detail.agents": "Applied Agents", @@ -114,6 +113,7 @@ "plugin.apps.center.filter.installable": "Installable", "plugin.apps.center.filter.installed": "Installed", "plugin.apps.center.installLocal": "Install Local App", + "plugin.apps.center.moreActions": "More actions", "plugin.apps.center.publish": "Publish Plugin", "plugin.apps.center.review": "Review Releases", "plugin.apps.center.pagination.next": "Next", @@ -141,6 +141,63 @@ "plugin.apps.center.title": "App Center", "plugin.apps.center.version.cloud": "Cloud {{version}}", "plugin.apps.center.version.current": "Current {{version}}", + "plugin.catalog.v2.auth.onUse": "Authorize when used", + "plugin.catalog.v2.cancel": "Cancel", + "plugin.catalog.v2.capabilityCount": "{{count}} capabilities", + "plugin.catalog.v2.close": "Close", + "plugin.catalog.v2.confirmInstall": "Install", + "plugin.catalog.v2.confirmUninstall": "Uninstall", + "plugin.catalog.v2.description": "Manage installed plugins and add standard plugin packages from a local folder.", + "plugin.catalog.v2.descriptionFallback": "No plugin description yet.", + "plugin.catalog.v2.detail.apps": "Apps", + "plugin.catalog.v2.detail.empty": "Select a plugin to inspect its capabilities and installation state.", + "plugin.catalog.v2.detail.eyebrow": "PLUGIN DETAILS", + "plugin.catalog.v2.detail.hooks": "Hooks", + "plugin.catalog.v2.detail.installToInspect": "Install this plugin to inspect its complete capability list and runtime state.", + "plugin.catalog.v2.detail.loading": "Loading plugin capabilities", + "plugin.catalog.v2.detail.mcp": "MCP servers", + "plugin.catalog.v2.detail.skills": "Skills", + "plugin.catalog.v2.details": "Details", + "plugin.catalog.v2.disable": "Disable", + "plugin.catalog.v2.dismissError": "Dismiss error", + "plugin.catalog.v2.empty.description": "Adjust the filters or choose a plugin folder.", + "plugin.catalog.v2.empty.noMatches": "No matching plugins", + "plugin.catalog.v2.empty.noPlugins": "No plugins to manage yet", + "plugin.catalog.v2.enable": "Enable", + "plugin.catalog.v2.eyebrow": "PLUGIN CATALOG", + "plugin.catalog.v2.install": "Install", + "plugin.catalog.v2.installLocal": "Install local plugin", + "plugin.catalog.v2.installSuccess": "Plugin installed", + "plugin.catalog.v2.loading": "Loading plugin catalog", + "plugin.catalog.v2.metric.hooks": "Hooks {{count}}", + "plugin.catalog.v2.metric.mcp": "MCP {{count}}", + "plugin.catalog.v2.metric.skills": "Skills {{count}}", + "plugin.catalog.v2.moreActions": "More actions", + "plugin.catalog.v2.noManifest": "No installable plugin was found in the selected folder.", + "plugin.catalog.v2.refresh": "Refresh plugin catalog", + "plugin.catalog.v2.review.choosePackage": "Choose a plugin to install", + "plugin.catalog.v2.review.eyebrow": "INSTALL REVIEW", + "plugin.catalog.v2.review.source": "Source: {{name}}", + "plugin.catalog.v2.review.title": "Review plugin capabilities", + "plugin.catalog.v2.searchLabel": "Search plugins", + "plugin.catalog.v2.searchPlaceholder": "Search names, descriptions, or capabilities", + "plugin.catalog.v2.selectSource": "Choose Plugin or Marketplace folder", + "plugin.catalog.v2.source.all": "All sources", + "plugin.catalog.v2.source.bundled": "Bundled", + "plugin.catalog.v2.source.local": "Local", + "plugin.catalog.v2.source.personal": "Personal", + "plugin.catalog.v2.source.repo": "Project", + "plugin.catalog.v2.sourceLabel": "Source", + "plugin.catalog.v2.status.disabled": "Disabled", + "plugin.catalog.v2.status.enabled": "Enabled", + "plugin.catalog.v2.title": "Plugin Center", + "plugin.catalog.v2.uninstall": "Uninstall", + "plugin.catalog.v2.uninstallDescription": "The package and installation record will be removed. Existing conversation history will be preserved.", + "plugin.catalog.v2.uninstallSuccess": "Plugin uninstalled", + "plugin.catalog.v2.uninstallTitle": "Uninstall {{name}}?", + "plugin.catalog.v2.version": "Version {{version}}", + "plugin.catalog.v2.view.all": "All", + "plugin.catalog.v2.view.installed": "Installed", "plugin.publish.action.preflight": "Run Preflight", "plugin.publish.action.preflighting": "Preflighting", "plugin.publish.action.publish": "Submit for review", @@ -904,6 +961,8 @@ "agentChat.fileManager.locations": "Locations", "agentChat.fileManager.preparingLocation": "Preparing file location", "agentChat.fileManager.refresh": "Refresh", + "agentChat.fileManager.showHiddenDirectories": "Show hidden directories", + "agentChat.fileManager.hideHiddenDirectories": "Hide hidden directories", "agentChat.fileManager.title": "Files", "agentChat.fileManager.toast.addedToChat": "Added to chat: {{name}}", "agentChat.fileManager.toast.copiedName": "Filename copied", @@ -1151,19 +1210,14 @@ "agentChat.messageImageAttachments.unavailable": "Image unavailable", "agentChat.navbar.appSwitcher.cloud": "Send to cloud", "agentChat.navbar.appSwitcher.cloudUnavailable": "Cloud sync is not available yet", - "agentChat.navbar.appSwitcher.codexWeb": "Connect Codex web", - "agentChat.navbar.appSwitcher.codexWebUnavailable": "Codex web connection is not available yet", + "agentChat.navbar.appSwitcher.codexWeb": "Connect web workspace", "agentChat.navbar.appSwitcher.continueUsing": "Continue using", - "agentChat.navbar.appSwitcher.cursor": "Cursor", - "agentChat.navbar.appSwitcher.finder": "Finder", "agentChat.navbar.appSwitcher.local": "Handle locally", "agentChat.navbar.appSwitcher.localUnavailable": "This project has no local folder", "agentChat.navbar.appSwitcher.open": "Open in", - "agentChat.navbar.appSwitcher.terminal": "Terminal", "agentChat.navbar.appSwitcher.toast.noProjectRoot": "This project has no local folder", "agentChat.navbar.appSwitcher.toast.openFailed": "Failed to open project: {{message}}", - "agentChat.navbar.appSwitcher.toast.codexWebFailed": "Failed to open Codex web: {{message}}", - "agentChat.navbar.appSwitcher.vscode": "VS Code", + "agentChat.navbar.appSwitcher.toast.codexWebFailed": "Failed to open web workspace: {{message}}", "agentChat.navbar.appSwitcher.worktree": "Worktree", "agentChat.navbar.appSwitcher.worktreeCreated": "Worktree created", "agentChat.navbar.appSwitcher.worktreeCreateFailed": "Failed to create worktree: {{message}}", @@ -1214,10 +1268,16 @@ "agentChat.pluginSurface.hostUnavailableBody": "No Electron WebContentsView bridge is available in this environment.", "agentChat.pluginSurface.hostUnavailableTitle": "Host unavailable", "agentChat.pluginSurface.closeTab": "Close", + "agentChat.pluginSurface.historyUnavailableBody": "The conversation and run result are still available, but this historical view cannot be reopened and the original action will not run again automatically.", + "agentChat.pluginSurface.historyUnavailableStatus": "History only", + "agentChat.pluginSurface.historyUnavailableTitle": "Plugin uninstalled", "agentChat.pluginSurface.loadFailedTitle": "Plugin failed to load", "agentChat.pluginSurface.loading": "Loading", "agentChat.pluginSurface.loadingBody": "Connecting to the Plugin.", "agentChat.pluginSurface.loadingTitle": "Opening Plugin", + "agentChat.pluginSurface.mcpApp.invalidMimeType": "This Plugin view format is not supported yet.", + "agentChat.pluginSurface.mcpApp.missingHtml": "This Plugin view has no content to display.", + "agentChat.pluginSurface.mcpApp.uriMismatch": "This Plugin view does not match the current result. Run it again.", "agentChat.pluginSurface.ready": "Connected", "agentChat.rightSurface.tabs.appSurface": "Plugin", "agentChat.rightSurface.tabs.browser": "Browser", diff --git a/src/i18n/resources/ja-JP/agent.json b/src/i18n/resources/ja-JP/agent.json index ca91737b2..65dd134c7 100644 --- a/src/i18n/resources/ja-JP/agent.json +++ b/src/i18n/resources/ja-JP/agent.json @@ -51,7 +51,6 @@ "plugin.apps.center.action.open": "アプリを開く", "plugin.apps.center.action.update": "更新", "plugin.apps.center.action.updateOneClick": "ワンクリック更新", - "plugin.apps.center.description": "Lime で開けるアプリを管理し、クラウドとローカルの出所を明確に表示します。", "plugin.apps.center.descriptionFallback": "アプリ説明はまだありません。", "plugin.apps.center.detail.appId": "アプリ識別子", "plugin.apps.center.detail.agents": "適用先 Agent", @@ -114,6 +113,7 @@ "plugin.apps.center.filter.installable": "インストール可能", "plugin.apps.center.filter.installed": "インストール済み", "plugin.apps.center.installLocal": "ローカルアプリをインストール", + "plugin.apps.center.moreActions": "その他の操作", "plugin.apps.center.publish": "プラグインを公開", "plugin.apps.center.review": "リリースをレビュー", "plugin.apps.center.pagination.next": "次へ", @@ -141,6 +141,63 @@ "plugin.apps.center.title": "アプリセンター", "plugin.apps.center.version.cloud": "クラウド {{version}}", "plugin.apps.center.version.current": "現在 {{version}}", + "plugin.catalog.v2.auth.onUse": "使用時に認証", + "plugin.catalog.v2.cancel": "キャンセル", + "plugin.catalog.v2.capabilityCount": "合計 {{count}} 件の機能", + "plugin.catalog.v2.close": "閉じる", + "plugin.catalog.v2.confirmInstall": "インストール", + "plugin.catalog.v2.confirmUninstall": "アンインストール", + "plugin.catalog.v2.description": "インストール済みプラグインを管理し、ローカルフォルダーから標準プラグインパッケージを追加します。", + "plugin.catalog.v2.descriptionFallback": "プラグインの説明はありません。", + "plugin.catalog.v2.detail.apps": "アプリ", + "plugin.catalog.v2.detail.empty": "プラグインを選択して機能とインストール状態を確認します。", + "plugin.catalog.v2.detail.eyebrow": "プラグイン詳細", + "plugin.catalog.v2.detail.hooks": "Hooks", + "plugin.catalog.v2.detail.installToInspect": "インストール後に、完全な機能一覧と実行状態を確認できます。", + "plugin.catalog.v2.detail.loading": "プラグイン機能を読み込み中", + "plugin.catalog.v2.detail.mcp": "MCP サーバー", + "plugin.catalog.v2.detail.skills": "スキル", + "plugin.catalog.v2.details": "詳細", + "plugin.catalog.v2.disable": "無効化", + "plugin.catalog.v2.dismissError": "エラーを閉じる", + "plugin.catalog.v2.empty.description": "フィルターを調整するか、プラグインフォルダーを選択してください。", + "plugin.catalog.v2.empty.noMatches": "一致するプラグインがありません", + "plugin.catalog.v2.empty.noPlugins": "管理できるプラグインはまだありません", + "plugin.catalog.v2.enable": "有効化", + "plugin.catalog.v2.eyebrow": "PLUGIN CATALOG", + "plugin.catalog.v2.install": "インストール", + "plugin.catalog.v2.installLocal": "ローカルプラグインを追加", + "plugin.catalog.v2.installSuccess": "プラグインをインストールしました", + "plugin.catalog.v2.loading": "プラグインカタログを読み込み中", + "plugin.catalog.v2.metric.hooks": "Hooks {{count}}", + "plugin.catalog.v2.metric.mcp": "MCP {{count}}", + "plugin.catalog.v2.metric.skills": "スキル {{count}}", + "plugin.catalog.v2.moreActions": "その他の操作", + "plugin.catalog.v2.noManifest": "選択したフォルダーにインストール可能なプラグインがありません。", + "plugin.catalog.v2.refresh": "プラグインカタログを更新", + "plugin.catalog.v2.review.choosePackage": "インストールするプラグインを選択", + "plugin.catalog.v2.review.eyebrow": "インストール確認", + "plugin.catalog.v2.review.source": "提供元:{{name}}", + "plugin.catalog.v2.review.title": "プラグイン機能を確認", + "plugin.catalog.v2.searchLabel": "プラグインを検索", + "plugin.catalog.v2.searchPlaceholder": "名前、説明、機能を検索", + "plugin.catalog.v2.selectSource": "Plugin または Marketplace フォルダーを選択", + "plugin.catalog.v2.source.all": "すべての提供元", + "plugin.catalog.v2.source.bundled": "内蔵", + "plugin.catalog.v2.source.local": "ローカル", + "plugin.catalog.v2.source.personal": "個人", + "plugin.catalog.v2.source.repo": "プロジェクト", + "plugin.catalog.v2.sourceLabel": "提供元", + "plugin.catalog.v2.status.disabled": "無効", + "plugin.catalog.v2.status.enabled": "有効", + "plugin.catalog.v2.title": "プラグインセンター", + "plugin.catalog.v2.uninstall": "アンインストール", + "plugin.catalog.v2.uninstallDescription": "パッケージとインストール記録を削除します。既存の会話履歴は保持されます。", + "plugin.catalog.v2.uninstallSuccess": "プラグインをアンインストールしました", + "plugin.catalog.v2.uninstallTitle": "{{name}} をアンインストールしますか?", + "plugin.catalog.v2.version": "バージョン {{version}}", + "plugin.catalog.v2.view.all": "すべて", + "plugin.catalog.v2.view.installed": "インストール済み", "plugin.publish.action.preflight": "公開前チェック", "plugin.publish.action.preflighting": "チェック中", "plugin.publish.action.publish": "レビューへ提出", @@ -904,6 +961,8 @@ "agentChat.fileManager.locations": "場所", "agentChat.fileManager.preparingLocation": "ファイルの場所を準備中", "agentChat.fileManager.refresh": "更新", + "agentChat.fileManager.showHiddenDirectories": "隠しフォルダーを表示", + "agentChat.fileManager.hideHiddenDirectories": "隠しフォルダーを隠す", "agentChat.fileManager.title": "ファイル", "agentChat.fileManager.toast.addedToChat": "チャットに追加しました:{{name}}", "agentChat.fileManager.toast.copiedName": "ファイル名をコピーしました", @@ -1151,19 +1210,14 @@ "agentChat.messageImageAttachments.unavailable": "画像を表示できません", "agentChat.navbar.appSwitcher.cloud": "クラウドに送信", "agentChat.navbar.appSwitcher.cloudUnavailable": "クラウド同期はまだ利用できません", - "agentChat.navbar.appSwitcher.codexWeb": "Codex web を連携", - "agentChat.navbar.appSwitcher.codexWebUnavailable": "Codex web 連携はまだ利用できません", + "agentChat.navbar.appSwitcher.codexWeb": "Web ワークスペースに接続", "agentChat.navbar.appSwitcher.continueUsing": "継続して使用", - "agentChat.navbar.appSwitcher.cursor": "Cursor", - "agentChat.navbar.appSwitcher.finder": "Finder", "agentChat.navbar.appSwitcher.local": "ローカルで処理", "agentChat.navbar.appSwitcher.localUnavailable": "このプロジェクトにはローカルフォルダーがありません", "agentChat.navbar.appSwitcher.open": "次で開く", - "agentChat.navbar.appSwitcher.terminal": "Terminal", "agentChat.navbar.appSwitcher.toast.noProjectRoot": "このプロジェクトにはローカルフォルダーがありません", "agentChat.navbar.appSwitcher.toast.openFailed": "プロジェクトを開けませんでした: {{message}}", - "agentChat.navbar.appSwitcher.toast.codexWebFailed": "Codex web を開けませんでした: {{message}}", - "agentChat.navbar.appSwitcher.vscode": "VS Code", + "agentChat.navbar.appSwitcher.toast.codexWebFailed": "Web ワークスペースを開けませんでした: {{message}}", "agentChat.navbar.appSwitcher.worktree": "ワークツリー", "agentChat.navbar.appSwitcher.worktreeCreated": "ワークツリーを作成しました", "agentChat.navbar.appSwitcher.worktreeCreateFailed": "ワークツリーの作成に失敗しました: {{message}}", @@ -1214,10 +1268,16 @@ "agentChat.pluginSurface.hostUnavailableBody": "この環境では Electron WebContentsView bridge を利用できません。", "agentChat.pluginSurface.hostUnavailableTitle": "ホストを利用できません", "agentChat.pluginSurface.closeTab": "閉じる", + "agentChat.pluginSurface.historyUnavailableBody": "会話と実行結果は保持されていますが、この履歴ビューを再度開くことはできず、元の操作が自動的に再実行されることもありません。", + "agentChat.pluginSurface.historyUnavailableStatus": "履歴のみ", + "agentChat.pluginSurface.historyUnavailableTitle": "Plugin はアンインストール済みです", "agentChat.pluginSurface.loadFailedTitle": "Plugin の読み込みに失敗しました", "agentChat.pluginSurface.loading": "読み込み中", "agentChat.pluginSurface.loadingBody": "Plugin に接続しています。", "agentChat.pluginSurface.loadingTitle": "Plugin を開いています", + "agentChat.pluginSurface.mcpApp.invalidMimeType": "この Plugin ビュー形式はまだサポートされていません。", + "agentChat.pluginSurface.mcpApp.missingHtml": "この Plugin ビューには表示できる内容がありません。", + "agentChat.pluginSurface.mcpApp.uriMismatch": "Plugin ビューが現在の結果と一致しません。もう一度実行してください。", "agentChat.pluginSurface.ready": "接続済み", "agentChat.rightSurface.tabs.appSurface": "Plugin", "agentChat.rightSurface.tabs.browser": "ブラウザー", diff --git a/src/i18n/resources/ko-KR/agent.json b/src/i18n/resources/ko-KR/agent.json index 700d3850f..1f28435fc 100644 --- a/src/i18n/resources/ko-KR/agent.json +++ b/src/i18n/resources/ko-KR/agent.json @@ -51,7 +51,6 @@ "plugin.apps.center.action.open": "앱 열기", "plugin.apps.center.action.update": "업데이트", "plugin.apps.center.action.updateOneClick": "원클릭 업데이트", - "plugin.apps.center.description": "Lime에서 열 수 있는 앱을 관리하고 클라우드와 로컬 출처를 명확히 표시합니다.", "plugin.apps.center.descriptionFallback": "아직 앱 설명이 없습니다.", "plugin.apps.center.detail.appId": "앱 식별자", "plugin.apps.center.detail.agents": "적용된 Agent", @@ -114,6 +113,7 @@ "plugin.apps.center.filter.installable": "설치 가능", "plugin.apps.center.filter.installed": "설치됨", "plugin.apps.center.installLocal": "로컬 앱 설치", + "plugin.apps.center.moreActions": "추가 작업", "plugin.apps.center.publish": "플러그인 게시", "plugin.apps.center.review": "릴리스 검토", "plugin.apps.center.pagination.next": "다음", @@ -141,6 +141,63 @@ "plugin.apps.center.title": "앱 센터", "plugin.apps.center.version.cloud": "클라우드 {{version}}", "plugin.apps.center.version.current": "현재 {{version}}", + "plugin.catalog.v2.auth.onUse": "사용 시 인증", + "plugin.catalog.v2.cancel": "취소", + "plugin.catalog.v2.capabilityCount": "기능 {{count}}개", + "plugin.catalog.v2.close": "닫기", + "plugin.catalog.v2.confirmInstall": "설치", + "plugin.catalog.v2.confirmUninstall": "제거", + "plugin.catalog.v2.description": "설치된 플러그인을 관리하고 로컬 폴더에서 표준 플러그인 패키지를 추가합니다.", + "plugin.catalog.v2.descriptionFallback": "플러그인 설명이 없습니다.", + "plugin.catalog.v2.detail.apps": "앱", + "plugin.catalog.v2.detail.empty": "플러그인을 선택하여 기능과 설치 상태를 확인하세요.", + "plugin.catalog.v2.detail.eyebrow": "플러그인 세부 정보", + "plugin.catalog.v2.detail.hooks": "Hooks", + "plugin.catalog.v2.detail.installToInspect": "설치 후 전체 기능 목록과 실행 상태를 확인할 수 있습니다.", + "plugin.catalog.v2.detail.loading": "플러그인 기능 불러오는 중", + "plugin.catalog.v2.detail.mcp": "MCP 서버", + "plugin.catalog.v2.detail.skills": "스킬", + "plugin.catalog.v2.details": "세부 정보", + "plugin.catalog.v2.disable": "비활성화", + "plugin.catalog.v2.dismissError": "오류 닫기", + "plugin.catalog.v2.empty.description": "필터를 조정하거나 플러그인 폴더를 선택하세요.", + "plugin.catalog.v2.empty.noMatches": "일치하는 플러그인이 없습니다", + "plugin.catalog.v2.empty.noPlugins": "관리할 플러그인이 아직 없습니다", + "plugin.catalog.v2.enable": "활성화", + "plugin.catalog.v2.eyebrow": "PLUGIN CATALOG", + "plugin.catalog.v2.install": "설치", + "plugin.catalog.v2.installLocal": "로컬 플러그인 설치", + "plugin.catalog.v2.installSuccess": "플러그인을 설치했습니다", + "plugin.catalog.v2.loading": "플러그인 카탈로그 불러오는 중", + "plugin.catalog.v2.metric.hooks": "Hooks {{count}}", + "plugin.catalog.v2.metric.mcp": "MCP {{count}}", + "plugin.catalog.v2.metric.skills": "스킬 {{count}}", + "plugin.catalog.v2.moreActions": "추가 작업", + "plugin.catalog.v2.noManifest": "선택한 폴더에 설치할 수 있는 플러그인이 없습니다.", + "plugin.catalog.v2.refresh": "플러그인 카탈로그 새로고침", + "plugin.catalog.v2.review.choosePackage": "설치할 플러그인 선택", + "plugin.catalog.v2.review.eyebrow": "설치 확인", + "plugin.catalog.v2.review.source": "출처: {{name}}", + "plugin.catalog.v2.review.title": "플러그인 기능 검토", + "plugin.catalog.v2.searchLabel": "플러그인 검색", + "plugin.catalog.v2.searchPlaceholder": "이름, 설명 또는 기능 검색", + "plugin.catalog.v2.selectSource": "Plugin 또는 Marketplace 폴더 선택", + "plugin.catalog.v2.source.all": "모든 출처", + "plugin.catalog.v2.source.bundled": "내장", + "plugin.catalog.v2.source.local": "로컬", + "plugin.catalog.v2.source.personal": "개인", + "plugin.catalog.v2.source.repo": "프로젝트", + "plugin.catalog.v2.sourceLabel": "출처", + "plugin.catalog.v2.status.disabled": "비활성", + "plugin.catalog.v2.status.enabled": "활성", + "plugin.catalog.v2.title": "플러그인 센터", + "plugin.catalog.v2.uninstall": "제거", + "plugin.catalog.v2.uninstallDescription": "플러그인 패키지와 설치 기록을 제거합니다. 기존 대화 기록은 유지됩니다.", + "plugin.catalog.v2.uninstallSuccess": "플러그인을 제거했습니다", + "plugin.catalog.v2.uninstallTitle": "{{name}} 플러그인을 제거할까요?", + "plugin.catalog.v2.version": "버전 {{version}}", + "plugin.catalog.v2.view.all": "전체", + "plugin.catalog.v2.view.installed": "설치됨", "plugin.publish.action.preflight": "게시 사전 검사", "plugin.publish.action.preflighting": "검사 중", "plugin.publish.action.publish": "검토 제출", @@ -904,6 +961,8 @@ "agentChat.fileManager.locations": "위치", "agentChat.fileManager.preparingLocation": "파일 위치 준비 중", "agentChat.fileManager.refresh": "새로고침", + "agentChat.fileManager.showHiddenDirectories": "숨김 폴더 표시", + "agentChat.fileManager.hideHiddenDirectories": "숨김 폴더 숨기기", "agentChat.fileManager.title": "파일", "agentChat.fileManager.toast.addedToChat": "대화에 추가됨: {{name}}", "agentChat.fileManager.toast.copiedName": "파일 이름을 복사했습니다", @@ -1151,19 +1210,14 @@ "agentChat.messageImageAttachments.unavailable": "이미지를 표시할 수 없습니다", "agentChat.navbar.appSwitcher.cloud": "클라우드로 보내기", "agentChat.navbar.appSwitcher.cloudUnavailable": "클라우드 동기화는 아직 사용할 수 없습니다", - "agentChat.navbar.appSwitcher.codexWeb": "Codex web 연결", - "agentChat.navbar.appSwitcher.codexWebUnavailable": "Codex web 연결은 아직 사용할 수 없습니다", + "agentChat.navbar.appSwitcher.codexWeb": "웹 워크스페이스 연결", "agentChat.navbar.appSwitcher.continueUsing": "계속 사용", - "agentChat.navbar.appSwitcher.cursor": "Cursor", - "agentChat.navbar.appSwitcher.finder": "Finder", "agentChat.navbar.appSwitcher.local": "로컬에서 처리", "agentChat.navbar.appSwitcher.localUnavailable": "이 프로젝트에는 로컬 폴더가 없습니다", "agentChat.navbar.appSwitcher.open": "다음에서 열기", - "agentChat.navbar.appSwitcher.terminal": "Terminal", "agentChat.navbar.appSwitcher.toast.noProjectRoot": "이 프로젝트에는 로컬 폴더가 없습니다", "agentChat.navbar.appSwitcher.toast.openFailed": "프로젝트를 열지 못했습니다: {{message}}", - "agentChat.navbar.appSwitcher.toast.codexWebFailed": "Codex web을 열지 못했습니다: {{message}}", - "agentChat.navbar.appSwitcher.vscode": "VS Code", + "agentChat.navbar.appSwitcher.toast.codexWebFailed": "웹 워크스페이스를 열지 못했습니다: {{message}}", "agentChat.navbar.appSwitcher.worktree": "워크트리", "agentChat.navbar.appSwitcher.worktreeCreated": "워크트리를 만들었습니다", "agentChat.navbar.appSwitcher.worktreeCreateFailed": "워크트리를 만들지 못했습니다: {{message}}", @@ -1214,10 +1268,16 @@ "agentChat.pluginSurface.hostUnavailableBody": "현재 환경에서 Electron WebContentsView bridge를 사용할 수 없습니다.", "agentChat.pluginSurface.hostUnavailableTitle": "호스트를 사용할 수 없음", "agentChat.pluginSurface.closeTab": "닫기", + "agentChat.pluginSurface.historyUnavailableBody": "대화와 실행 결과는 그대로 유지되지만 이 기록 보기는 다시 열 수 없으며 기존 작업도 자동으로 다시 실행되지 않습니다.", + "agentChat.pluginSurface.historyUnavailableStatus": "기록만 유지", + "agentChat.pluginSurface.historyUnavailableTitle": "Plugin이 제거됨", "agentChat.pluginSurface.loadFailedTitle": "Plugin 로드 실패", "agentChat.pluginSurface.loading": "로드 중", "agentChat.pluginSurface.loadingBody": "Plugin에 연결하는 중입니다.", "agentChat.pluginSurface.loadingTitle": "Plugin 여는 중", + "agentChat.pluginSurface.mcpApp.invalidMimeType": "이 Plugin 보기 형식은 아직 지원되지 않습니다.", + "agentChat.pluginSurface.mcpApp.missingHtml": "이 Plugin 보기에 표시할 콘텐츠가 없습니다.", + "agentChat.pluginSurface.mcpApp.uriMismatch": "Plugin 보기가 현재 결과와 일치하지 않습니다. 다시 실행해 주세요.", "agentChat.pluginSurface.ready": "연결됨", "agentChat.rightSurface.tabs.appSurface": "Plugin", "agentChat.rightSurface.tabs.browser": "브라우저", diff --git a/src/i18n/resources/zh-CN/agent.json b/src/i18n/resources/zh-CN/agent.json index 98fcd5231..4f9a75b38 100644 --- a/src/i18n/resources/zh-CN/agent.json +++ b/src/i18n/resources/zh-CN/agent.json @@ -51,7 +51,6 @@ "plugin.apps.center.action.open": "打开应用", "plugin.apps.center.action.update": "更新", "plugin.apps.center.action.updateOneClick": "一键更新", - "plugin.apps.center.description": "管理可以在 Lime 里打开的应用,清楚区分云端和本地来源。", "plugin.apps.center.descriptionFallback": "暂无应用说明。", "plugin.apps.center.detail.appId": "应用标识", "plugin.apps.center.detail.agents": "应用到的 Agent", @@ -114,6 +113,7 @@ "plugin.apps.center.filter.installable": "可安装", "plugin.apps.center.filter.installed": "已安装", "plugin.apps.center.installLocal": "安装本地应用", + "plugin.apps.center.moreActions": "更多操作", "plugin.apps.center.publish": "发布插件", "plugin.apps.center.review": "审核发布", "plugin.apps.center.pagination.next": "下一页", @@ -141,6 +141,63 @@ "plugin.apps.center.title": "应用中心", "plugin.apps.center.version.cloud": "云端 {{version}}", "plugin.apps.center.version.current": "当前 {{version}}", + "plugin.catalog.v2.auth.onUse": "使用时授权", + "plugin.catalog.v2.cancel": "取消", + "plugin.catalog.v2.capabilityCount": "共 {{count}} 项能力", + "plugin.catalog.v2.close": "关闭", + "plugin.catalog.v2.confirmInstall": "确认安装", + "plugin.catalog.v2.confirmUninstall": "确认卸载", + "plugin.catalog.v2.description": "管理已安装的插件,并从本地目录添加标准插件包。", + "plugin.catalog.v2.descriptionFallback": "暂无插件说明。", + "plugin.catalog.v2.detail.apps": "应用", + "plugin.catalog.v2.detail.empty": "选择一个插件查看能力和安装状态。", + "plugin.catalog.v2.detail.eyebrow": "插件详情", + "plugin.catalog.v2.detail.hooks": "Hooks", + "plugin.catalog.v2.detail.installToInspect": "安装后可以查看完整能力清单和运行状态。", + "plugin.catalog.v2.detail.loading": "正在读取插件能力", + "plugin.catalog.v2.detail.mcp": "MCP 服务", + "plugin.catalog.v2.detail.skills": "技能", + "plugin.catalog.v2.details": "详情", + "plugin.catalog.v2.disable": "停用", + "plugin.catalog.v2.dismissError": "关闭错误提示", + "plugin.catalog.v2.empty.description": "调整筛选条件,或选择一个插件目录。", + "plugin.catalog.v2.empty.noMatches": "没有符合条件的插件", + "plugin.catalog.v2.empty.noPlugins": "还没有可管理的插件", + "plugin.catalog.v2.enable": "启用", + "plugin.catalog.v2.eyebrow": "PLUGIN CATALOG", + "plugin.catalog.v2.install": "安装", + "plugin.catalog.v2.installLocal": "安装本地插件", + "plugin.catalog.v2.installSuccess": "插件安装完成", + "plugin.catalog.v2.loading": "正在读取插件目录", + "plugin.catalog.v2.metric.hooks": "Hooks {{count}}", + "plugin.catalog.v2.metric.mcp": "MCP {{count}}", + "plugin.catalog.v2.metric.skills": "技能 {{count}}", + "plugin.catalog.v2.moreActions": "更多操作", + "plugin.catalog.v2.noManifest": "所选目录中没有可安装的插件。", + "plugin.catalog.v2.refresh": "刷新插件目录", + "plugin.catalog.v2.review.choosePackage": "选择要安装的插件", + "plugin.catalog.v2.review.eyebrow": "安装确认", + "plugin.catalog.v2.review.source": "来源:{{name}}", + "plugin.catalog.v2.review.title": "检查插件能力", + "plugin.catalog.v2.searchLabel": "搜索插件", + "plugin.catalog.v2.searchPlaceholder": "搜索名称、说明或能力", + "plugin.catalog.v2.selectSource": "选择 Plugin 或 Marketplace 目录", + "plugin.catalog.v2.source.all": "全部来源", + "plugin.catalog.v2.source.bundled": "内置", + "plugin.catalog.v2.source.local": "本地", + "plugin.catalog.v2.source.personal": "个人目录", + "plugin.catalog.v2.source.repo": "项目目录", + "plugin.catalog.v2.sourceLabel": "来源", + "plugin.catalog.v2.status.disabled": "已停用", + "plugin.catalog.v2.status.enabled": "已启用", + "plugin.catalog.v2.title": "插件中心", + "plugin.catalog.v2.uninstall": "卸载", + "plugin.catalog.v2.uninstallDescription": "插件包和安装记录将被移除;既有对话历史不会被删除。", + "plugin.catalog.v2.uninstallSuccess": "插件已卸载", + "plugin.catalog.v2.uninstallTitle": "卸载 {{name}}?", + "plugin.catalog.v2.version": "版本 {{version}}", + "plugin.catalog.v2.view.all": "全部", + "plugin.catalog.v2.view.installed": "已安装", "plugin.publish.action.preflight": "发布预检", "plugin.publish.action.preflighting": "预检中", "plugin.publish.action.publish": "提交审核", @@ -904,6 +961,8 @@ "agentChat.fileManager.locations": "位置", "agentChat.fileManager.preparingLocation": "正在准备文件位置", "agentChat.fileManager.refresh": "刷新", + "agentChat.fileManager.showHiddenDirectories": "显示隐藏目录", + "agentChat.fileManager.hideHiddenDirectories": "隐藏隐藏目录", "agentChat.fileManager.title": "文件", "agentChat.fileManager.toast.addedToChat": "已添加到对话:{{name}}", "agentChat.fileManager.toast.copiedName": "已复制文件名", @@ -1151,19 +1210,14 @@ "agentChat.messageImageAttachments.unavailable": "图片暂时无法显示", "agentChat.navbar.appSwitcher.cloud": "发送至云端", "agentChat.navbar.appSwitcher.cloudUnavailable": "云端同步尚未接入", - "agentChat.navbar.appSwitcher.codexWeb": "关联 Codex web", - "agentChat.navbar.appSwitcher.codexWebUnavailable": "Codex web 关联尚未接入", + "agentChat.navbar.appSwitcher.codexWeb": "连接网页工作区", "agentChat.navbar.appSwitcher.continueUsing": "继续使用", - "agentChat.navbar.appSwitcher.cursor": "Cursor", - "agentChat.navbar.appSwitcher.finder": "Finder", "agentChat.navbar.appSwitcher.local": "在本地处理", "agentChat.navbar.appSwitcher.localUnavailable": "当前项目缺少本地目录", "agentChat.navbar.appSwitcher.open": "打开位置", - "agentChat.navbar.appSwitcher.terminal": "Terminal", "agentChat.navbar.appSwitcher.toast.noProjectRoot": "当前项目缺少本地目录", "agentChat.navbar.appSwitcher.toast.openFailed": "打开项目失败:{{message}}", - "agentChat.navbar.appSwitcher.toast.codexWebFailed": "打开 Codex web 失败:{{message}}", - "agentChat.navbar.appSwitcher.vscode": "VS Code", + "agentChat.navbar.appSwitcher.toast.codexWebFailed": "打开网页工作区失败:{{message}}", "agentChat.navbar.appSwitcher.worktree": "工作树", "agentChat.navbar.appSwitcher.worktreeCreated": "工作树已创建", "agentChat.navbar.appSwitcher.worktreeCreateFailed": "创建工作树失败:{{message}}", @@ -1214,10 +1268,16 @@ "agentChat.pluginSurface.hostUnavailableBody": "当前环境没有可用的 Electron WebContentsView bridge。", "agentChat.pluginSurface.hostUnavailableTitle": "宿主不可用", "agentChat.pluginSurface.closeTab": "关闭", + "agentChat.pluginSurface.historyUnavailableBody": "对话和运行结果仍会保留,但这个历史界面无法再次打开,也不会自动重跑原操作。", + "agentChat.pluginSurface.historyUnavailableStatus": "仅保留历史", + "agentChat.pluginSurface.historyUnavailableTitle": "Plugin 已卸载", "agentChat.pluginSurface.loadFailedTitle": "Plugin 加载失败", "agentChat.pluginSurface.loading": "正在加载", "agentChat.pluginSurface.loadingBody": "正在连接 Plugin。", "agentChat.pluginSurface.loadingTitle": "正在打开 Plugin", + "agentChat.pluginSurface.mcpApp.invalidMimeType": "这个插件界面的格式暂不支持。", + "agentChat.pluginSurface.mcpApp.missingHtml": "这个插件界面没有可显示的内容。", + "agentChat.pluginSurface.mcpApp.uriMismatch": "插件界面与当前结果不一致,请重新运行。", "agentChat.pluginSurface.ready": "已连接", "agentChat.rightSurface.tabs.appSurface": "Plugin", "agentChat.rightSurface.tabs.browser": "浏览器", diff --git a/src/i18n/resources/zh-TW/agent.json b/src/i18n/resources/zh-TW/agent.json index 461c29a63..f4bbd0c97 100644 --- a/src/i18n/resources/zh-TW/agent.json +++ b/src/i18n/resources/zh-TW/agent.json @@ -51,7 +51,6 @@ "plugin.apps.center.action.open": "開啟應用", "plugin.apps.center.action.update": "更新", "plugin.apps.center.action.updateOneClick": "一鍵更新", - "plugin.apps.center.description": "管理可以在 Lime 裡開啟的應用,清楚區分雲端和本地來源。", "plugin.apps.center.descriptionFallback": "暫無應用說明。", "plugin.apps.center.detail.appId": "應用識別碼", "plugin.apps.center.detail.agents": "應用到的 Agent", @@ -114,6 +113,7 @@ "plugin.apps.center.filter.installable": "可安裝", "plugin.apps.center.filter.installed": "已安裝", "plugin.apps.center.installLocal": "安裝本地應用", + "plugin.apps.center.moreActions": "更多操作", "plugin.apps.center.publish": "發布插件", "plugin.apps.center.review": "審核發布", "plugin.apps.center.pagination.next": "下一頁", @@ -141,6 +141,63 @@ "plugin.apps.center.title": "應用中心", "plugin.apps.center.version.cloud": "雲端 {{version}}", "plugin.apps.center.version.current": "目前 {{version}}", + "plugin.catalog.v2.auth.onUse": "使用時授權", + "plugin.catalog.v2.cancel": "取消", + "plugin.catalog.v2.capabilityCount": "共 {{count}} 項能力", + "plugin.catalog.v2.close": "關閉", + "plugin.catalog.v2.confirmInstall": "確認安裝", + "plugin.catalog.v2.confirmUninstall": "確認解除安裝", + "plugin.catalog.v2.description": "管理已安裝的插件,並從本機目錄加入標準插件套件。", + "plugin.catalog.v2.descriptionFallback": "暫無插件說明。", + "plugin.catalog.v2.detail.apps": "應用", + "plugin.catalog.v2.detail.empty": "選擇一個插件以查看能力和安裝狀態。", + "plugin.catalog.v2.detail.eyebrow": "插件詳情", + "plugin.catalog.v2.detail.hooks": "Hooks", + "plugin.catalog.v2.detail.installToInspect": "安裝後可以查看完整能力清單和執行狀態。", + "plugin.catalog.v2.detail.loading": "正在讀取插件能力", + "plugin.catalog.v2.detail.mcp": "MCP 服務", + "plugin.catalog.v2.detail.skills": "技能", + "plugin.catalog.v2.details": "詳情", + "plugin.catalog.v2.disable": "停用", + "plugin.catalog.v2.dismissError": "關閉錯誤提示", + "plugin.catalog.v2.empty.description": "調整篩選條件,或選擇一個插件目錄。", + "plugin.catalog.v2.empty.noMatches": "沒有符合條件的插件", + "plugin.catalog.v2.empty.noPlugins": "尚無可管理的插件", + "plugin.catalog.v2.enable": "啟用", + "plugin.catalog.v2.eyebrow": "PLUGIN CATALOG", + "plugin.catalog.v2.install": "安裝", + "plugin.catalog.v2.installLocal": "安裝本機插件", + "plugin.catalog.v2.installSuccess": "插件安裝完成", + "plugin.catalog.v2.loading": "正在讀取插件目錄", + "plugin.catalog.v2.metric.hooks": "Hooks {{count}}", + "plugin.catalog.v2.metric.mcp": "MCP {{count}}", + "plugin.catalog.v2.metric.skills": "技能 {{count}}", + "plugin.catalog.v2.moreActions": "更多操作", + "plugin.catalog.v2.noManifest": "所選目錄中沒有可安裝的插件。", + "plugin.catalog.v2.refresh": "重新整理插件目錄", + "plugin.catalog.v2.review.choosePackage": "選擇要安裝的插件", + "plugin.catalog.v2.review.eyebrow": "安裝確認", + "plugin.catalog.v2.review.source": "來源:{{name}}", + "plugin.catalog.v2.review.title": "檢查插件能力", + "plugin.catalog.v2.searchLabel": "搜尋插件", + "plugin.catalog.v2.searchPlaceholder": "搜尋名稱、說明或能力", + "plugin.catalog.v2.selectSource": "選擇 Plugin 或 Marketplace 目錄", + "plugin.catalog.v2.source.all": "全部來源", + "plugin.catalog.v2.source.bundled": "內建", + "plugin.catalog.v2.source.local": "本機", + "plugin.catalog.v2.source.personal": "個人目錄", + "plugin.catalog.v2.source.repo": "專案目錄", + "plugin.catalog.v2.sourceLabel": "來源", + "plugin.catalog.v2.status.disabled": "已停用", + "plugin.catalog.v2.status.enabled": "已啟用", + "plugin.catalog.v2.title": "插件中心", + "plugin.catalog.v2.uninstall": "解除安裝", + "plugin.catalog.v2.uninstallDescription": "插件套件和安裝記錄將被移除;既有對話歷史不會被刪除。", + "plugin.catalog.v2.uninstallSuccess": "插件已解除安裝", + "plugin.catalog.v2.uninstallTitle": "解除安裝 {{name}}?", + "plugin.catalog.v2.version": "版本 {{version}}", + "plugin.catalog.v2.view.all": "全部", + "plugin.catalog.v2.view.installed": "已安裝", "plugin.publish.action.preflight": "發布預檢", "plugin.publish.action.preflighting": "預檢中", "plugin.publish.action.publish": "提交審核", @@ -904,6 +961,8 @@ "agentChat.fileManager.locations": "位置", "agentChat.fileManager.preparingLocation": "正在準備檔案位置", "agentChat.fileManager.refresh": "重新整理", + "agentChat.fileManager.showHiddenDirectories": "顯示隱藏目錄", + "agentChat.fileManager.hideHiddenDirectories": "隱藏隱藏目錄", "agentChat.fileManager.title": "檔案", "agentChat.fileManager.toast.addedToChat": "已加入對話:{{name}}", "agentChat.fileManager.toast.copiedName": "已複製檔名", @@ -1151,19 +1210,14 @@ "agentChat.messageImageAttachments.unavailable": "圖片暫時無法顯示", "agentChat.navbar.appSwitcher.cloud": "傳送至雲端", "agentChat.navbar.appSwitcher.cloudUnavailable": "雲端同步尚未接入", - "agentChat.navbar.appSwitcher.codexWeb": "關聯 Codex web", - "agentChat.navbar.appSwitcher.codexWebUnavailable": "Codex web 關聯尚未接入", + "agentChat.navbar.appSwitcher.codexWeb": "連接網頁工作區", "agentChat.navbar.appSwitcher.continueUsing": "繼續使用", - "agentChat.navbar.appSwitcher.cursor": "Cursor", - "agentChat.navbar.appSwitcher.finder": "Finder", "agentChat.navbar.appSwitcher.local": "在本機處理", "agentChat.navbar.appSwitcher.localUnavailable": "目前專案缺少本機目錄", "agentChat.navbar.appSwitcher.open": "打開位置", - "agentChat.navbar.appSwitcher.terminal": "Terminal", "agentChat.navbar.appSwitcher.toast.noProjectRoot": "目前專案缺少本機目錄", "agentChat.navbar.appSwitcher.toast.openFailed": "打開專案失敗:{{message}}", - "agentChat.navbar.appSwitcher.toast.codexWebFailed": "打開 Codex web 失敗:{{message}}", - "agentChat.navbar.appSwitcher.vscode": "VS Code", + "agentChat.navbar.appSwitcher.toast.codexWebFailed": "開啟網頁工作區失敗:{{message}}", "agentChat.navbar.appSwitcher.worktree": "工作樹", "agentChat.navbar.appSwitcher.worktreeCreated": "工作樹已建立", "agentChat.navbar.appSwitcher.worktreeCreateFailed": "建立工作樹失敗:{{message}}", @@ -1214,10 +1268,16 @@ "agentChat.pluginSurface.hostUnavailableBody": "目前環境沒有可用的 Electron WebContentsView bridge。", "agentChat.pluginSurface.hostUnavailableTitle": "宿主不可用", "agentChat.pluginSurface.closeTab": "關閉", + "agentChat.pluginSurface.historyUnavailableBody": "對話與執行結果仍會保留,但這個歷史介面無法再次開啟,也不會自動重新執行原操作。", + "agentChat.pluginSurface.historyUnavailableStatus": "僅保留歷史", + "agentChat.pluginSurface.historyUnavailableTitle": "Plugin 已解除安裝", "agentChat.pluginSurface.loadFailedTitle": "Plugin 載入失敗", "agentChat.pluginSurface.loading": "正在載入", "agentChat.pluginSurface.loadingBody": "正在連接 Plugin。", "agentChat.pluginSurface.loadingTitle": "正在打開 Plugin", + "agentChat.pluginSurface.mcpApp.invalidMimeType": "目前不支援這個插件介面格式。", + "agentChat.pluginSurface.mcpApp.missingHtml": "這個插件介面沒有可顯示的內容。", + "agentChat.pluginSurface.mcpApp.uriMismatch": "插件介面與目前結果不一致,請重新執行。", "agentChat.pluginSurface.ready": "已連接", "agentChat.rightSurface.tabs.appSurface": "Plugin", "agentChat.rightSurface.tabs.browser": "瀏覽器", diff --git a/src/lib/api/agentRuntime/appServerCanonicalItemReader.test.ts b/src/lib/api/agentRuntime/appServerCanonicalItemReader.test.ts index a2fbf7683..d2086fdfe 100644 --- a/src/lib/api/agentRuntime/appServerCanonicalItemReader.test.ts +++ b/src/lib/api/agentRuntime/appServerCanonicalItemReader.test.ts @@ -472,6 +472,7 @@ describe("readCanonicalThreadItem", () => { connectorId: "connector-docs", resourceUri: "docs://codex", }, + mcpAppResourceUri: "ui://plugin/docs.html", pluginId: "plugin-docs", result: { content, @@ -498,6 +499,7 @@ describe("readCanonicalThreadItem", () => { connectorId: "connector-docs", resourceUri: "docs://codex", }, + mcp_app_resource_uri: "ui://plugin/docs.html", plugin_id: "plugin-docs", result_content: content, result_meta: { requestId: "request-1" }, diff --git a/src/lib/api/agentRuntime/appServerCanonicalItemReader.ts b/src/lib/api/agentRuntime/appServerCanonicalItemReader.ts index 7678bacc2..e66f6ddc0 100644 --- a/src/lib/api/agentRuntime/appServerCanonicalItemReader.ts +++ b/src/lib/api/agentRuntime/appServerCanonicalItemReader.ts @@ -498,6 +498,7 @@ function readCanonicalToolMetadata( canonical_type: type, server, app_context: appContext, + mcp_app_resource_uri: readString(item, "mcpAppResourceUri"), plugin_id: readString(item, "pluginId"), result_content: result?.content, result_meta: result?._meta, diff --git a/src/lib/api/appServerClientMethodSpecs.ts b/src/lib/api/appServerClientMethodSpecs.ts index bbbc51dbc..2e1a3f814 100644 --- a/src/lib/api/appServerClientMethodSpecs.ts +++ b/src/lib/api/appServerClientMethodSpecs.ts @@ -232,6 +232,36 @@ export const APP_SERVER_CLIENT_METHODS: readonly AppServerClientMethodSpec[] = [ method: constants.APP_SERVER_METHOD_MEDIA_READ, params: "required", }, + { + name: "setPluginCatalogEnabled", + method: constants.APP_SERVER_METHOD_PLUGIN_ENABLED_SET, + params: "required", + }, + { + name: "listPluginCatalog", + method: constants.APP_SERVER_METHOD_PLUGIN_LIST, + params: "optional-empty", + }, + { + name: "readPluginCatalog", + method: constants.APP_SERVER_METHOD_PLUGIN_READ, + params: "required", + }, + { + name: "installPluginCatalog", + method: constants.APP_SERVER_METHOD_PLUGIN_INSTALL, + params: "required", + }, + { + name: "uninstallPluginCatalog", + method: constants.APP_SERVER_METHOD_PLUGIN_UNINSTALL, + params: "required", + }, + { + name: "listInstalledPluginCatalog", + method: constants.APP_SERVER_METHOD_PLUGIN_INSTALLED, + params: "optional-empty", + }, { name: "readAgentSessionToolInventory", method: constants.APP_SERVER_METHOD_AGENT_SESSION_TOOL_INVENTORY_READ, diff --git a/src/lib/api/appServerClientMethods.ts b/src/lib/api/appServerClientMethods.ts index 5cc914bab..b345d4811 100644 --- a/src/lib/api/appServerClientMethods.ts +++ b/src/lib/api/appServerClientMethods.ts @@ -61,6 +61,36 @@ declare module "./appServerClient" { ): Promise< appServer.AppServerRequestResult >; + listPluginCatalog( + params?: appServer.AppServerPluginCatalogListParams, + ): Promise< + appServer.AppServerRequestResult + >; + setPluginCatalogEnabled( + params: appServer.AppServerPluginCatalogEnabledSetParams, + ): Promise< + appServer.AppServerRequestResult + >; + readPluginCatalog( + params: appServer.AppServerPluginCatalogReadParams, + ): Promise< + appServer.AppServerRequestResult + >; + installPluginCatalog( + params: appServer.AppServerPluginCatalogInstallParams, + ): Promise< + appServer.AppServerRequestResult + >; + uninstallPluginCatalog( + params: appServer.AppServerPluginCatalogUninstallParams, + ): Promise< + appServer.AppServerRequestResult + >; + listInstalledPluginCatalog( + params?: appServer.AppServerPluginCatalogInstalledParams, + ): Promise< + appServer.AppServerRequestResult + >; listDirectory( params: appServer.AppServerFileSystemListDirectoryParams, ): Promise< diff --git a/src/lib/api/appServerConstants.ts b/src/lib/api/appServerConstants.ts index 5dad39b91..2d4a8161a 100644 --- a/src/lib/api/appServerConstants.ts +++ b/src/lib/api/appServerConstants.ts @@ -76,6 +76,15 @@ export const APP_SERVER_METHOD_THREAD_ARCHIVE = protocol.METHOD_THREAD_ARCHIVE; export const APP_SERVER_METHOD_THREAD_UNARCHIVE = protocol.METHOD_THREAD_UNARCHIVE; export const APP_SERVER_METHOD_MEDIA_READ = protocol.METHOD_MEDIA_READ; +export const APP_SERVER_METHOD_PLUGIN_LIST = protocol.METHOD_PLUGIN_LIST; +export const APP_SERVER_METHOD_PLUGIN_READ = protocol.METHOD_PLUGIN_READ; +export const APP_SERVER_METHOD_PLUGIN_INSTALL = protocol.METHOD_PLUGIN_INSTALL; +export const APP_SERVER_METHOD_PLUGIN_UNINSTALL = + protocol.METHOD_PLUGIN_UNINSTALL; +export const APP_SERVER_METHOD_PLUGIN_INSTALLED = + protocol.METHOD_PLUGIN_INSTALLED; +export const APP_SERVER_METHOD_PLUGIN_ENABLED_SET = + protocol.METHOD_PLUGIN_ENABLED_SET; export const APP_SERVER_METHOD_AGENT_SESSION_TOOL_INVENTORY_READ = protocol.METHOD_AGENT_SESSION_TOOL_INVENTORY_READ; export const APP_SERVER_METHOD_THREAD_DELETE = protocol.METHOD_THREAD_DELETE; diff --git a/src/lib/api/appServerTypes.ts b/src/lib/api/appServerTypes.ts index 06fe48e82..e4a735fbc 100644 --- a/src/lib/api/appServerTypes.ts +++ b/src/lib/api/appServerTypes.ts @@ -122,6 +122,31 @@ export type AppServerAgentSessionListResponse = export type AppServerAgentSessionReadParams = protocol.AgentSessionReadParams; export type AppServerMediaReadParams = protocol.MediaReadParams; export type AppServerMediaReadResponse = protocol.MediaReadResponse; +export type AppServerPluginCatalogListParams = protocol.PluginCatalogListParams; +export type AppServerPluginCatalogListResponse = + protocol.PluginCatalogListResponse; +export type AppServerPluginCatalogSummary = protocol.PluginCatalogSummary; +export type AppServerPluginCatalogDetail = protocol.PluginCatalogDetail; +export type AppServerPluginCatalogCapability = protocol.PluginCatalogCapability; +export type AppServerPluginCatalogHook = protocol.PluginCatalogHook; +export type AppServerPluginCatalogUiResource = protocol.PluginCatalogUiResource; +export type AppServerPluginCatalogReadParams = protocol.PluginCatalogReadParams; +export type AppServerPluginCatalogReadResponse = + protocol.PluginCatalogReadResponse; +export type AppServerPluginCatalogInstallParams = + protocol.PluginCatalogInstallParams; +export type AppServerPluginCatalogInstallResponse = + protocol.PluginCatalogInstallResponse; +export type AppServerPluginCatalogUninstallParams = + protocol.PluginCatalogUninstallParams; +export type AppServerPluginCatalogUninstallResponse = + protocol.PluginCatalogUninstallResponse; +export type AppServerPluginCatalogInstalledParams = + protocol.PluginCatalogInstalledParams; +export type AppServerPluginCatalogEnabledSetParams = + protocol.PluginCatalogEnabledSetParams; +export type AppServerPluginCatalogEnabledSetResponse = + protocol.PluginCatalogEnabledSetResponse; export type AppServerAgentSessionToolInventoryReadParams = protocol.AgentSessionToolInventoryReadParams; export type AppServerAgentInput = protocol.AgentInput; diff --git a/src/lib/api/embeddedBrowser.ts b/src/lib/api/embeddedBrowser.ts index 928eb93b2..c62857a34 100644 --- a/src/lib/api/embeddedBrowser.ts +++ b/src/lib/api/embeddedBrowser.ts @@ -78,15 +78,32 @@ export interface EmbeddedBrowserPermissionRequestEvent { interface EmbeddedBrowserCommandParams extends Record { viewId: string; + leaseId?: string; url?: string; bounds?: EmbeddedBrowserBounds; visible?: boolean; } +export interface EmbeddedBrowserHtmlParams + extends + Record, + Pick { + html: string; + csp?: { + baseUriDomains?: string[]; + connectDomains?: string[]; + frameDomains?: string[]; + resourceDomains?: string[]; + }; + source: "mcpApp"; + sourceUri: string; +} + const EMBEDDED_BROWSER_REQUIRED_COMMANDS = [ "embedded_browser_view_mount", "embedded_browser_view_set_bounds", "embedded_browser_view_navigate", + "embedded_browser_view_load_html", "embedded_browser_view_reload", "embedded_browser_view_stop", "embedded_browser_view_find_in_page", @@ -153,6 +170,12 @@ export async function navigateEmbeddedBrowserView( return invokeEmbeddedBrowser("embedded_browser_view_navigate", params); } +export async function loadEmbeddedBrowserViewHtml( + params: EmbeddedBrowserHtmlParams, +): Promise { + return invokeEmbeddedBrowser("embedded_browser_view_load_html", params); +} + export async function reloadEmbeddedBrowserView( viewId: string, ): Promise { @@ -206,8 +229,12 @@ export async function goForwardEmbeddedBrowserView( export async function destroyEmbeddedBrowserView( viewId: string, + leaseId?: string, ): Promise { - await safeInvoke("embedded_browser_view_destroy", { viewId }); + await safeInvoke("embedded_browser_view_destroy", { + viewId, + ...(leaseId ? { leaseId } : {}), + }); } export function listenEmbeddedBrowserViewState( diff --git a/src/lib/api/mcp.test.ts b/src/lib/api/mcp.test.ts index 40bf28d66..a97ee2080 100644 --- a/src/lib/api/mcp.test.ts +++ b/src/lib/api/mcp.test.ts @@ -115,6 +115,23 @@ describe("mcp", () => { expect(appServerRequestMock).not.toHaveBeenCalled(); }); + it("runtime resource owner 应要求完整 canonical identity", async () => { + await expect( + mcpApi.readResource("docs", "ui://demo/report.html", { + sessionId: " ", + threadId: "thread-1", + }), + ).rejects.toThrow("sessionId cannot be empty"); + await expect( + mcpApi.readResource("docs", "ui://demo/report.html", { + sessionId: "session-1", + threadId: " ", + }), + ).rejects.toThrow("threadId cannot be empty"); + + expect(appServerRequestMock).not.toHaveBeenCalled(); + }); + it("提示词操作应拒绝空白 target 且不发送请求", async () => { await expect(mcpApi.getPrompt(" ", "summarize", {})).rejects.toThrow( "server cannot be empty", @@ -377,6 +394,30 @@ describe("mcp", () => { uri: "docs://readme", }); + mockAppServerResult({ + uri: "ui://demo/report.html", + mime_type: "text/html;profile=mcp-app", + text: "
Plugin report
", + meta: { ui: { csp: { connectDomains: ["https://api.example.com"] } } }, + }); + await expect( + mcpApi.readResource("plugin__demo__server", "ui://demo/report.html", { + sessionId: "session-1", + threadId: "thread-1", + }), + ).resolves.toEqual( + expect.objectContaining({ + uri: "ui://demo/report.html", + meta: expect.objectContaining({ ui: expect.any(Object) }), + }), + ); + expect(appServerRequestMock).toHaveBeenLastCalledWith("mcpResource/read", { + server: "plugin__demo__server", + uri: "ui://demo/report.html", + sessionId: "session-1", + threadId: "thread-1", + }); + mockAppServerResult({}); await expect( mcpApi.subscribeResource("docs", "docs://readme"), diff --git a/src/lib/api/mcp.ts b/src/lib/api/mcp.ts index 7d89646e1..b2b3c12bc 100644 --- a/src/lib/api/mcp.ts +++ b/src/lib/api/mcp.ts @@ -88,6 +88,21 @@ function requireMcpResourceTarget(server: string, uri: string) { return { server: normalizedServer, uri: normalizedUri }; } +function requireMcpRuntimeOwner(owner: { + sessionId: string; + threadId: string; +}) { + const sessionId = owner.sessionId.trim(); + const threadId = owner.threadId.trim(); + if (!sessionId) { + throw new Error("MCP runtime sessionId cannot be empty"); + } + if (!threadId) { + throw new Error("MCP runtime threadId cannot be empty"); + } + return { sessionId, threadId }; +} + function requireMcpPromptTarget(server: string, name: string) { const normalizedServer = server.trim(); const normalizedName = name.trim(); @@ -375,12 +390,14 @@ export const mcpApi = { readResource: async ( server: string, uri: string, + runtimeOwner?: { sessionId: string; threadId: string }, ): Promise => { const target = requireMcpResourceTarget(server, uri); + const owner = runtimeOwner ? requireMcpRuntimeOwner(runtimeOwner) : {}; const response = await requestMcpAppServer( METHOD_MCP_RESOURCE_READ, - target, + { ...target, ...owner }, ); return assertMcpResourceContent(METHOD_MCP_RESOURCE_READ, response); }, diff --git a/src/lib/api/mcpResponseGuards.ts b/src/lib/api/mcpResponseGuards.ts index 3206ac7f9..794f087cd 100644 --- a/src/lib/api/mcpResponseGuards.ts +++ b/src/lib/api/mcpResponseGuards.ts @@ -146,7 +146,11 @@ export function assertMcpResourceContent( typeof record.uri !== "string" || (record.mime_type !== undefined && typeof record.mime_type !== "string") || (record.text !== undefined && typeof record.text !== "string") || - (record.blob !== undefined && typeof record.blob !== "string") + (record.blob !== undefined && typeof record.blob !== "string") || + (record.meta !== undefined && + (!record.meta || + typeof record.meta !== "object" || + Array.isArray(record.meta))) ) { throw new Error(`${method} did not return resource content`); } diff --git a/src/lib/api/mcpTypes.ts b/src/lib/api/mcpTypes.ts index e14e03b78..1658cd1c4 100644 --- a/src/lib/api/mcpTypes.ts +++ b/src/lib/api/mcpTypes.ts @@ -294,4 +294,5 @@ export interface McpResourceContent { mime_type?: string; text?: string; blob?: string; + meta?: Record; } diff --git a/src/lib/api/pluginCatalog.test.ts b/src/lib/api/pluginCatalog.test.ts new file mode 100644 index 000000000..2625ed2f5 --- /dev/null +++ b/src/lib/api/pluginCatalog.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PLUGIN_CATALOG_CHANGED_EVENT, + installPluginCatalog, + listInstalledPluginCatalog, + listPluginCatalog, + readPluginCatalog, + setPluginCatalogEnabled, + uninstallPluginCatalog, + type PluginCatalogClient, +} from "./pluginCatalog"; + +function client(): PluginCatalogClient { + return { + listPluginCatalog: vi + .fn() + .mockResolvedValue({ result: { plugins: [], generatedAt: "now" } }), + setPluginCatalogEnabled: vi + .fn() + .mockResolvedValue({ result: { plugin: {} } }), + readPluginCatalog: vi.fn().mockResolvedValue({ result: { plugin: {} } }), + installPluginCatalog: vi.fn().mockResolvedValue({ result: { plugin: {} } }), + uninstallPluginCatalog: vi + .fn() + .mockResolvedValue({ result: { pluginId: "demo", uninstalled: true } }), + listInstalledPluginCatalog: vi + .fn() + .mockResolvedValue({ result: { plugins: [], generatedAt: "now" } }), + }; +} + +describe("Plugin v2 catalog API", () => { + it("只通过 typed App Server client 访问 catalog/install owner", async () => { + const appServerClient = client(); + + await listPluginCatalog({ query: "demo" }, appServerClient); + await readPluginCatalog({ pluginId: "demo" }, appServerClient); + await setPluginCatalogEnabled( + { pluginId: "demo", enabled: true }, + appServerClient, + ); + await installPluginCatalog( + { sourcePath: "/tmp/demo-plugin" }, + appServerClient, + ); + await listInstalledPluginCatalog({}, appServerClient); + await uninstallPluginCatalog({ pluginId: "demo" }, appServerClient); + + expect(appServerClient.listPluginCatalog).toHaveBeenCalledWith({ + query: "demo", + }); + expect(appServerClient.setPluginCatalogEnabled).toHaveBeenCalledWith({ + pluginId: "demo", + enabled: true, + }); + expect(appServerClient.readPluginCatalog).toHaveBeenCalledWith({ + pluginId: "demo", + }); + expect(appServerClient.installPluginCatalog).toHaveBeenCalledWith({ + sourcePath: "/tmp/demo-plugin", + }); + expect(appServerClient.listInstalledPluginCatalog).toHaveBeenCalledWith({}); + expect(appServerClient.uninstallPluginCatalog).toHaveBeenCalledWith({ + pluginId: "demo", + }); + }); + + it("安装、启停和卸载后应通知 v2 catalog 消费者刷新", async () => { + const appServerClient = client(); + const listener = vi.fn(); + window.addEventListener(PLUGIN_CATALOG_CHANGED_EVENT, listener); + + try { + await installPluginCatalog( + { sourcePath: "/tmp/demo-plugin" }, + appServerClient, + ); + await setPluginCatalogEnabled( + { pluginId: "demo", enabled: false }, + appServerClient, + ); + await uninstallPluginCatalog({ pluginId: "demo" }, appServerClient); + } finally { + window.removeEventListener(PLUGIN_CATALOG_CHANGED_EVENT, listener); + } + + expect(listener).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/lib/api/pluginCatalog.ts b/src/lib/api/pluginCatalog.ts new file mode 100644 index 000000000..d594abfec --- /dev/null +++ b/src/lib/api/pluginCatalog.ts @@ -0,0 +1,96 @@ +import { AppServerClient } from "@/lib/api/appServer"; +import { open as openDesktopDialog } from "@/lib/desktop-host/plugin-dialog"; +import type { + AppServerPluginCatalogEnabledSetParams, + AppServerPluginCatalogEnabledSetResponse, + AppServerPluginCatalogInstallParams, + AppServerPluginCatalogInstallResponse, + AppServerPluginCatalogInstalledParams, + AppServerPluginCatalogListParams, + AppServerPluginCatalogListResponse, + AppServerPluginCatalogReadParams, + AppServerPluginCatalogReadResponse, + AppServerPluginCatalogUninstallParams, + AppServerPluginCatalogUninstallResponse, +} from "@/lib/api/appServerTypes"; + +export type PluginCatalogClient = Pick< + AppServerClient, + | "listPluginCatalog" + | "setPluginCatalogEnabled" + | "readPluginCatalog" + | "installPluginCatalog" + | "uninstallPluginCatalog" + | "listInstalledPluginCatalog" +>; + +export const PLUGIN_CATALOG_CHANGED_EVENT = "lime:plugin-catalog-changed"; + +function emitPluginCatalogChanged(): void { + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent(PLUGIN_CATALOG_CHANGED_EVENT)); + } +} + +export interface SelectPluginCatalogSourceOptions { + title?: string; +} + +export async function selectPluginCatalogSource( + options: SelectPluginCatalogSourceOptions = {}, +): Promise { + const selected = await openDesktopDialog({ + title: options.title, + directory: true, + multiple: false, + }); + return typeof selected === "string" ? selected : null; +} + +export async function listPluginCatalog( + params: AppServerPluginCatalogListParams = {}, + client: PluginCatalogClient = new AppServerClient(), +): Promise { + return (await client.listPluginCatalog(params)).result; +} + +export async function setPluginCatalogEnabled( + params: AppServerPluginCatalogEnabledSetParams, + client: PluginCatalogClient = new AppServerClient(), +): Promise { + const result = (await client.setPluginCatalogEnabled(params)).result; + emitPluginCatalogChanged(); + return result; +} + +export async function readPluginCatalog( + params: AppServerPluginCatalogReadParams, + client: PluginCatalogClient = new AppServerClient(), +): Promise { + return (await client.readPluginCatalog(params)).result; +} + +export async function installPluginCatalog( + params: AppServerPluginCatalogInstallParams, + client: PluginCatalogClient = new AppServerClient(), +): Promise { + const result = (await client.installPluginCatalog(params)).result; + emitPluginCatalogChanged(); + return result; +} + +export async function uninstallPluginCatalog( + params: AppServerPluginCatalogUninstallParams, + client: PluginCatalogClient = new AppServerClient(), +): Promise { + const result = (await client.uninstallPluginCatalog(params)).result; + emitPluginCatalogChanged(); + return result; +} + +export async function listInstalledPluginCatalog( + params: AppServerPluginCatalogInstalledParams = {}, + client: PluginCatalogClient = new AppServerClient(), +): Promise { + return (await client.listInstalledPluginCatalog(params)).result; +} diff --git a/src/lib/governance/codexModelNativeToolPolicyOrigin.test.ts b/src/lib/governance/codexModelNativeToolPolicyOrigin.test.ts index f214bb456..787785282 100644 --- a/src/lib/governance/codexModelNativeToolPolicyOrigin.test.ts +++ b/src/lib/governance/codexModelNativeToolPolicyOrigin.test.ts @@ -185,10 +185,13 @@ describe("Codex model native tool policy origin", () => { } expect(codexSpecPlanSource).toContain( - "turn_context.model_info.apply_patch_tool_type.is_some()", + "environment_mode.has_environment() && turn_context.model_info.apply_patch_tool_type.is_some()", + ); + expect(codexSpecPlanSource).toContain( + "registry.add(ApplyPatchHandler::new(include_environment_id));", ); - expect(codexSpecPlanSource).toContain("planned_tools.add(ApplyPatchHandler"); expect(codexSpecPlanSource).toContain(".experimental_supported_tools"); expect(codexSpecPlanSource).toContain('tool == "test_sync_tool"'); + expect(codexSpecPlanSource).toContain("registry.add(TestSyncHandler);"); }); });