From 8ba98a75dd4db7ab8d071d18e9701647e6f50d79 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 5 Nov 2026 15:01:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20=E6=99=AE=E9=80=9A=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=8F=AF=E5=A3=B0=E6=98=8E=20hostIntegration=20?= =?UTF-8?q?=E4=B8=8E=E7=9F=AD=E5=B7=A5=E5=85=B7=20ID=EF=BC=8C=E5=86=B2?= =?UTF-8?q?=E7=AA=81=E6=97=B6=E4=BA=89=E7=94=A8=E5=8D=B3=E4=B8=8D=E5=BD=92?= =?UTF-8?q?=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Core:languageServers[].hostIntegration 不再限 dev.lithe 命名空间; toolchains.registry 输入去掉 verifiedOfficial,被多个已启用插件定义的 短工具 ID 不归任何一方,结果新增 conflicts: [{tool, pluginIDs}],其余 定义照常注册;插件 ID 重复仍整批拒绝。 - macOS:删除 ToolchainSettingsPluginSource.verifiedOfficial;设置页加载器 向 Core 取 conflicts,被争用的工具不列出并给出 i18n 诊断,provider 胶囊 同步过滤。 - 契约、rust-core-api、Agent Note 同步;新增 Rust 与 Swift 测试。 --- ...a-language-server-as-declarative-plugin.md | 6 +- .../2026-10-31-plugin-injected-toolchains.md | 16 ++-- ...4-java-extraction-generic-apis-and-gate.md | 2 +- macos/Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../Platform/MacOS/MacServiceContainer.swift | 5 +- .../MacToolchainDiscovery+Resolution.swift | 2 +- .../MacOS/Runtime/MacToolchainDiscovery.swift | 2 +- .../Runtime/MacToolchainSettingsLoader.swift | 28 +++++- .../MacOS/ToolchainSettingsDemo.swift | 2 +- .../MacBuildToolResolverTests.swift | 2 +- ...cNativeBuildProviderIntegrationTests.swift | 6 +- .../MacToolchainDiscoveryTests.swift | 88 ++++++++++++++++--- .../ToolchainSettingsLoaderTests.swift | 58 ++++++++++++ rust/lithe-core/src/plugins/declarative.rs | 29 +++--- .../src/tests/declarative_plugins.rs | 17 ++++ rust/lithe-core/src/tests/plugin_run_debug.rs | 3 +- .../src/tests/toolchain_definitions.rs | 80 ++++++++++++++--- .../lithe-core/src/tests/toolchain_queries.rs | 2 +- .../src/tests/toolchain_resolution.rs | 33 ++++++- rust/lithe-core/src/toolchain/definitions.rs | 48 +++++----- rust/lithe-core/src/toolchain/mod.rs | 3 +- rust/lithe-core/src/toolchain/query.rs | 4 +- .../language-specifics-baseline.json | 2 +- shared/contracts/plugins/v1/README.md | 30 ++++--- .../fixtures/manifest/invalid-manifests.json | 6 -- .../plugins/v1/plugin-manifest.schema.json | 4 +- .../contracts/plugins/v1/toolchains/README.md | 39 +++++--- shared/contracts/rust-core-api.md | 2 +- shared/i18n/locales/en-US.json | 1 + shared/i18n/locales/zh-CN.json | 1 + shared/i18n/messages.json | 18 ++++ 32 files changed, 416 insertions(+), 127 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md b/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md index e3ffb2c1cf..ea2c4b1fbf 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md +++ b/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md @@ -42,7 +42,7 @@ Java 的语言服务器 JDT LS(Eclipse 的 Java 语言服务器,提供补全 - `launch.kind: "runtime"`:用运行时启动包内入口文件,不需要启动脚本。运行时有两种来源:插件自带(`packagedRuntime`,宿主只启动包内这一个文件),或由宿主发现并按 `minimumVersion` 检查版本。 - 启动模板变量 `${pluginDir}`、`${stateDir}`、`${workspaceStateDir}`、`${arch}`,以及 `initializationOptions`。 - `settings`:用户可调的选项(整数、枚举、布尔),在启动模板里写 `${setting:}` 引用。Java Support 用它暴露 JDT LS 的最大堆内存和日志级别,决策见 [声明式插件设置](../feature/2026-10-14-declarative-plugin-settings.md)。 -- `hostIntegration`:`{ id, capabilities, resources }`。只有 `dev.lithe` 命名空间的插件可以声明,因为它让受信任的宿主代码去读插件的文件。 +- `hostIntegration`:`{ id, capabilities, resources }`。任何插件都可以声明,不再限于 `dev.lithe` 命名空间(最初只给官方命名空间,2026-11-05 放开)。放开的理由:集成虽然是宿主代码,但只读取声明它的插件自己包内的资源,并把它们交给该插件自己启动的语言服务器或 Java 子进程(扩展 bundle、Lombok agent、测试运行器、调试),没有给插件增加任何权限;普通插件经用户在安装确认窗口确认后,本来就能运行自带的服务器进程。宿主不认识的集成名仍以 `plugin.integration.unsupported` 拒绝。构建系统的 `hostIntegration`(目前只有 `maven`)是另一个声明,仍只给官方命名空间,规则见[构建系统插件契约](2026-10-17-build-system-plugin-contract.md)。 - 两个 Core 命令:`plugins.declarative.languageServerLaunchPlan` 展开启动计划,`plugins.declarative.checkRuntimeVersion` 解析并比较运行时版本。 API 1 的行为完全不变:已发布的 PHP、Go 包声明 API 1,其参数里的 `${...}` 仍按字面量传递。 @@ -177,6 +177,10 @@ dev.lithe.plugin.java-support/ 能把 Java 的全部逻辑搬出宿主,也是 Maven 这类带业务逻辑的模块最终需要的形态。但它要先设计取消、超时、崩溃隔离和权限,而 JDT LS 的启动和资源归属用声明就能解决。两者不冲突,协议可以之后单独提案。 +### `hostIntegration` 继续只给官方命名空间 + +最初的做法。它假定“集成运行受信任的宿主代码,所以只能让官方插件用”。2026-11-05 放弃:Java 插件改为普通插件(不再区分官方和普通插件),这条限制会让它的下载版被宿主拒绝;而且限制没有保护到东西,因为集成读取的路径都必须在声明它的插件包内,交给的也只是这个插件自己启动的进程,普通插件经用户确认安装后本来就能运行自带的服务器进程。放开后宿主仍只执行自己实现的集成,不认识的名字被拒绝,所以普通插件拿到的是“按名字启用一段宿主已有的逻辑”,不是执行任意代码的新途径。 + ## 后果 收益: diff --git a/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md b/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md index 12b89248f4..0e5c09b3c5 100644 --- a/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md +++ b/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md @@ -47,7 +47,9 @@ ### 2. ID 和执行权限不能靠声明自证 -工具 ID 是显式关联,不靠命令名关联。社区插件定义自己的工具时使用 `:`;不带前缀的短名只允许经宿主验证的官方签名插件定义。这里的“官方”来自安装验证后的发布者身份,不能来自清单中的自称字段、插件 ID 前缀或显示名称。重复 ID 拒绝后来的注册者,不允许覆盖已有定义。 +工具 ID 是显式关联,不靠命令名关联。任何插件都可以定义不带前缀的短名(如 `java`),也可以定义限定名 `:`;宿主不再给 Core 提供“是否官方”的来源证明(2026-11-05 起,原先短名只允许经宿主验证的官方签名插件定义)。限定名的前缀必须是定义者自己的插件 ID,所以不同插件的限定名不会冲突。 + +短名可能撞车。同一个短名被多个已启用插件定义时,它不归属任何一方:这些定义一个都不注册,其余定义照常注册,`toolchains.registry` 的结果用 `conflicts: [{tool, pluginIDs}]` 报告每个被争用的工具(按 `tool` 排序,`pluginIDs` 也排序)。被争用的工具不会出现在 `toolchains.discoveryPlan` 里,使用方解析它时按“无人声明”处理。macOS 设置页为每个冲突显示一条诊断,提示用户只保留一个插件启用;Swift 不替用户挑插件。插件 ID 重复仍然整批拒绝,不允许覆盖已有定义。 社区提供方可以回答 `java` 等短名,但结果只对它已声明、请求已授权、且存在已启用定义的工具生效。提供方不能通过返回 `git` 的同名文件取得宿主命令替换权。安装审查须列出被影响的工具、运行的后端和探测参数;`toolchains.provide` 的说明必须表达“影响工具选择”,不能只说读取目录。 @@ -140,6 +142,8 @@ macOS 的声明式路线由 `MacToolchainDiscovery` 执行,宿主不认识任 - **安装发现只用提供方。** 表达能力最强,但目录规律固定的工具也要打包原生后端、走进程生命周期,并在安装后重启;这类工具保留声明式扫描作为不运行插件代码的路线。 - **让版本管理器直接改 PATH 或注入任意命令。** 能解决 Dock 的环境缺失,却把发现权限扩大成替换宿主程序的权限,也丢掉工具 ID、来源和使用方关系,因此不采用。 - **提供方主动推送并为每个项目常驻。** 可减少部分查询,但增加生命周期与一致性成本;当前请求式方案已有取消、期限和输入失效边界,先不新增常驻推送协议。 +- **短名只允许经宿主验证的官方签名插件定义(旧规则)。** 它能防止社区插件占用 `java`,但需要宿主向 Core 提供“是否官方”的证据,并要求每个定义短名的插件都是官方插件。2026-11-05 放弃:Lithe 不再区分官方插件和普通插件,Java 插件以普通插件 `io.github.lithe-idea.java-support` 发布,这条规则会让它无法定义 `java`;改用限定名又会让使用方(例如 Maven 的 Java 前置条件)写死某个 Java 插件的 ID。 +- **短名先到先得,或按插件 ID 排序决定归属。** 这两种做法都能给出唯一答案,但顺序可以被恶意插件操纵:先到先得让它抢在 Java 插件前面启用,按插件 ID 排序让它起一个排在前面的 ID,结果都是 `java` 指向它自己的可执行文件布局。排序结果还会因为用户装了一个无关插件而改变。因此改为“争用即不归属”:谁都拿不到,用户在设置页看到冲突和涉及的插件,自己决定保留哪个。 - **每个运行时一条选择链或一个设置表单。** 局部实现直接,但 JDK、Maven 和其他命令继续出现不同优先级;选择、显示与执行会再次漂移,因此采用同一候选机制和通用展示骨架。 ## 验收标准 @@ -157,7 +161,8 @@ macOS 的声明式路线由 `MacToolchainDiscovery` 执行,宿主不认识任 ## 风险 -- **声明会影响启动目标。** 清单 schema 只能检查形状,不能证明官方身份、文件存在或可执行;实现必须同时具备安装验证、平台文件证据、Core 通用限制和启动边界复核。 +- **声明会影响启动目标。** 清单 schema 只能检查形状,不能证明文件存在或可执行;实现必须同时具备安装验证、平台文件证据、Core 通用限制和启动边界复核。 +- **短名争用会让工具暂时没有人声明。** “争用即不归属”能防止抢占,但一个用户确认安装的插件也能通过定义同名短名,让 `java` 在它启用期间没有任何定义,相关设置行和使用方随之报告缺少定义。此时设置页能列出涉及的插件,用户停用其一即可恢复;宿主不会静默改变任何启动目标,也没有为此增加自动裁决。 - **版本比较再次泄漏工具语义。** 已安装版本需要统一的可比较结果,未知版本不能靠工具名修补;Java 8 的特殊输出应由定义的探测规则处理,无法规范的版本不能静默参与“最新”选择。 - **文件监听遗漏或查询失败。** 新建文件、符号链接替换和丢失事件可能留下旧快照,必须覆盖目录身份变化和失败状态;不能把失败翻译成“已更新”。 - **源优先级仍可能冲突。** 原生与兼容声明之间、多个原生项目要求之间不能仅靠稳定排序得到业务答案;需要明确冲突结果与用户可行动入口。 @@ -171,7 +176,7 @@ macOS 的声明式路线由 `MacToolchainDiscovery` 执行,宿主不认识任 - `./scripts/agent-notes/verify-agent-notes.sh`:中文笔记格式、生命周期、链接与路径。 - `./scripts/architecture/verify-shared-contracts.sh`:现行共享契约及 JSON 有效性;不检查 Core 语义,语义由下面的 Core 测试保护。 - `./scripts/platform-feature-matrix/verify-platform-feature-matrix.sh`:矩阵记录实际 Core 切片与平台待接入状态,不把纯数据测试标成产品运行时已验证。 -- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib toolchain_definitions -- --test-threads=1`:短 ID 来源、重复归属、嵌套字段失败即拒绝、只报告安装的提供方,以及定义插件为自己的工具声明提供方。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib toolchain_definitions -- --test-threads=1`:任何插件可定义短 ID、多个插件定义同一短 ID 时都不注册且 `conflicts` 排序稳定、其余定义照常注册、重复插件 ID 整批拒绝、嵌套字段失败即拒绝、只报告安装的提供方,以及定义插件为自己的工具声明提供方。 - `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib toolchain_pins -- --test-threads=1`:目录按组件继承、失效数据、跨平台路径语法、数量边界和 legacy 数据保留。 - `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib toolchain_resolution -- --test-threads=1`:通过正式命令验证四层选择、真实回复解码、冲突和版本规则,以及设置页候选的登录 Shell home、跳过原因和自动选择;文件事实与探测结果由隔离快照注入,不访问本机工具。 - macOS 稳定性测试 `--filter 'MacToolchainDiscoveryTests|ToolchainSettings'`:真实临时目录与注入的 home 和探测,验证扫描、隐藏项与未匹配条目、工作区链接不探测、同版本冲突、拒绝的声明不扫描、Core 解析出的生效层与未满足要求、固定值只在自己的目录生效且不进入自动检测,以及隔离预览样例清单通过 Core 准入;不运行本机安装的工具。 @@ -198,8 +203,8 @@ Build 和 Provider Run 新增独立的运行前解析,复用这里的扫描、 设置页的检测结果来授权进程。用户目录 pin 只读,解析后重新核对原生路径、安装与来源 文件身份、工作区上下文及插件清单;Wrapper 只在显式执行时允许,不作被动版本探测。 -Maven 社区插件使用 `:maven` 定义,Java Support 的短 ID 仍要求独立官方来源 -证明。`executableResolution: 2` 同时供工具定义和已有 Build toolchain 候选使用,不能把 +Maven 社区插件使用 `:maven` 定义。短 ID(如 `java`)不再要求独立官方来源 +证明,任何插件都可以定义;多个已启用插件定义同一短 ID 时都不注册(见第 2 节)。`executableResolution: 2` 同时供工具定义和已有 Build toolchain 候选使用,不能把 后者误判成“没有定义就未使用能力”,也不能放开旧候选的未知字段。Windows 和旧 Java 消费者的切换尚未完成,原有选择链只可随真实消费者迁移逐项删除。 @@ -220,6 +225,7 @@ Maven 社区插件使用 `:maven` 定义,Java Support 的短 ID 仍 - **每行读哪个目录。** 有固定值的行读固定值所在目录的结果;其余行读最近一个有自己版本文件的目录(没有就是根目录)。 - **已知限制。** 一是 `PATH` 上的命令永远不会被 Core 选中,因为页面不传 `commandHomes`;Core 跳过所有层时,页面把这条命令当作“版本未验证”的兜底显示,后续见 [#1641](https://github.com/1lck/Lithe-IDEA/issues/1641)。二是 `mvnw`、`gradlew` 这类项目文件候选不参与解析,因为 `discoveryPlan` 不提供它们的路径,也没有项目代码授权;要支持它就得改插件契约,不在本轮做,后续见 [#1640](https://github.com/1lck/Lithe-IDEA/issues/1640)。 - **冲突要用户选,现在只能看。** Core 遇到同层版本管理器不一致时返回 `chooseProvider`,遇到同版本的多个安装时返回 `chooseInstallation`,并且明确不允许靠排序替用户决定。页面现在只显示冲突原因,没有选择按钮,因为写入还没实现。计划中,用户的选择存成本机 `local.json` 里的固定值:固定值格式已在契约里,优先级最高,还会被 `.lithe/.gitignore` 排除。`providerChoices` 的存放位置契约里没有定义,单独保存它需要先改契约。选择入口、固定值的添加与切换以及 Lithe 全局默认的写入,后续见 [#1639](https://github.com/1lck/Lithe-IDEA/issues/1639)。 +- **短 ID 争用只显示,不选择。** `toolchains.registry` 返回的 `conflicts` 每项对应设置页上的一条诊断,内容是被争用的工具和涉及的插件,并提示只保留一个插件启用。页面不在 Swift 里挑一个插件,也不给被争用的工具生成行;用户停用其一后,下一次加载时工具恢复。 ### 阶段 D 的使用方默认值与符号继承(实现中) diff --git a/.agents/notes/proposed/architecture/2026-11-04-java-extraction-generic-apis-and-gate.md b/.agents/notes/proposed/architecture/2026-11-04-java-extraction-generic-apis-and-gate.md index 5d7a07d582..9416ffffed 100644 --- a/.agents/notes/proposed/architecture/2026-11-04-java-extraction-generic-apis-and-gate.md +++ b/.agents/notes/proposed/architecture/2026-11-04-java-extraction-generic-apis-and-gate.md @@ -219,7 +219,7 @@ A0 提交时的基线快照(以 baseline 文件为准): | 编号 | 决定 | | --- | --- | -| Q1 | 2026-11-05 改定:不再区分官方插件和普通插件,Java 插件和 Cargo、Maven、Gradle 一样是普通插件,ID 为 `io.github.lithe-idea.java-support`,vendor 为 `Lithe Community`,`signatureRequirement: "none"`,不再带发布者签名和 `packageTrust`,从外部仓库 [Lithe-IDEA/lithe-plugin-java](https://github.com/Lithe-IDEA/lithe-plugin-java) 构建,由 Lithe Store 自动收录。工具短 ID `java` 保留。E1 必须先做两件事,否则宿主会拒绝这个包:一是去掉 `languageServers[].hostIntegration` 按 ID 命名空间判定官方的限制(`plugins/declarative.rs` 的 `is_official_namespace`);二是允许普通插件声明不带 `:` 的短工具 ID(`toolchain/definitions.rs` 的 `verifiedOfficial` 要求)。旧 ID `dev.lithe.plugin.java-support` 按 [#1357](https://github.com/1lck/Lithe-IDEA/pull/1357) 迁移 Go 和 PHP 的做法处理:保留在 `OfficialPluginCatalog` 并填写 `communityReplacementID`,引导用户换装新 ID。在此之前 Java 仓库只发预发布 | +| Q1 | 2026-11-05 改定:不再区分官方插件和普通插件,Java 插件和 Cargo、Maven、Gradle 一样是普通插件,ID 为 `io.github.lithe-idea.java-support`,vendor 为 `Lithe Community`,`signatureRequirement: "none"`,不再带发布者签名和 `packageTrust`,从外部仓库 [Lithe-IDEA/lithe-plugin-java](https://github.com/Lithe-IDEA/lithe-plugin-java) 构建,由 Lithe Store 自动收录。工具短 ID `java` 保留。E1 必须先做两件事,否则宿主会拒绝这个包,两件事都已完成:(a) `languageServers[].hostIntegration` 不再按 ID 命名空间判定官方,任何插件都可以声明(构建系统的 `hostIntegration: maven` 不变,仍只给官方命名空间,后续整体删除);(b) 普通插件可以定义不带 `:` 的短工具 ID,`toolchains.registry` 不再接收 `verifiedOfficial`,多个已启用插件定义同一短 ID 时按“争用即不归属”处理:这些定义都不注册,结果里的 `conflicts` 列出它们,macOS 设置页为每个冲突显示一条诊断。旧 ID `dev.lithe.plugin.java-support` 按 [#1357](https://github.com/1lck/Lithe-IDEA/pull/1357) 迁移 Go 和 PHP 的做法处理:保留在 `OfficialPluginCatalog` 并填写 `communityReplacementID`,引导用户换装新 ID。这项迁移推迟到 lithe-plugin-java 发布第一个 Release 时再做,否则会把用户引向不存在的插件。在此之前 Java 仓库只发预发布 | | Q2 | E1 之后旧版 1.1.0 必须更新,不保留兼容翻译 | | Q3 | 共享前端内置的 TextMate 语法、主题、文件关联里的 Java 项,算通用语言目录数据,进白名单 | | Q4 | 作废:不做商店索引 v2 | diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index a8238cd376..a0edb62280 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -4693,6 +4693,7 @@ "Tokens of context this Agent may use. The context ring reads as full at this size. Codex applies it as its context window; for Claude Code the model decides its own size, so this sets where it compacts when no threshold is given. Leave empty for the Agent's default. Applies after reconnecting." = "Tokens of context this Agent may use. The context ring reads as full at this size. Codex applies it as its context window; for Claude Code the model decides its own size, so this sets where it compacts when no threshold is given. Leave empty for the Agent's default. Applies after reconnecting."; "Too many files to check for one tool; some installations were skipped." = "Too many files to check for one tool; some installations were skipped."; "Tool" = "Tool"; +"Tool %@ is defined by more than one enabled plugin (%@), so none of these definitions is used. Keep only one of these plugins enabled." = "Tool %@ is defined by more than one enabled plugin (%@), so none of these definitions is used. Keep only one of these plugins enabled."; "Tool Window" = "Tool Window"; "Tool Windows" = "Tool Windows"; "Tool activity (%lld)" = "Tool activity (%lld)"; @@ -9815,6 +9816,7 @@ "native.toolchainSettings.theProjectRoot" = "the project root"; "native.toolchainSettings.theSavedToolchainSettingsAreInvalid" = "The saved toolchain settings are invalid."; "native.toolchainSettings.tooManyFilesForOneTool" = "Too many files to check for one tool; some installations were skipped."; +"native.toolchainSettings.toolDefinedBySeveralPlugins" = "Tool %1$@ is defined by more than one enabled plugin (%2$@), so none of these definitions is used. Keep only one of these plugins enabled."; "native.toolchainSettings.toolDefinitionInfo" = "This row comes from the tool definition of an enabled plugin. Lithe has no built-in tools."; "native.toolchainSettings.toolchainSources" = "Toolchain Sources"; "native.toolchainSettings.toolsInfo" = "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 847fffcf32..aadbe2cb9f 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -4693,6 +4693,7 @@ "Tokens of context this Agent may use. The context ring reads as full at this size. Codex applies it as its context window; for Claude Code the model decides its own size, so this sets where it compacts when no threshold is given. Leave empty for the Agent's default. Applies after reconnecting." = "该 Agent 可使用的上下文 Token 数,上下文圆环以此为满。Codex 把它作为上下文窗口;Claude Code 的窗口由模型决定,没有设置压缩阈值时,它决定压缩的位置。留空使用 Agent 默认值。重新连接后生效。"; "Too many files to check for one tool; some installations were skipped." = "单个工具需要检查的文件过多,已跳过部分安装。"; "Tool" = "工具"; +"Tool %@ is defined by more than one enabled plugin (%@), so none of these definitions is used. Keep only one of these plugins enabled." = "多个已启用的插件都定义了工具 %@(%@),这些定义都不会使用。请只保留其中一个插件启用。"; "Tool Window" = "工具窗口"; "Tool Windows" = "工具窗口"; "Tool activity (%lld)" = "工具操作(%lld)"; @@ -9815,6 +9816,7 @@ "native.toolchainSettings.theProjectRoot" = "项目根目录"; "native.toolchainSettings.theSavedToolchainSettingsAreInvalid" = "已保存的工具链设置无效。"; "native.toolchainSettings.tooManyFilesForOneTool" = "单个工具需要检查的文件过多,已跳过部分安装。"; +"native.toolchainSettings.toolDefinedBySeveralPlugins" = "多个已启用的插件都定义了工具 %1$@(%2$@),这些定义都不会使用。请只保留其中一个插件启用。"; "native.toolchainSettings.toolDefinitionInfo" = "这一行来自已启用插件的工具定义,Lithe 不内置任何工具。"; "native.toolchainSettings.toolchainSources" = "环境来源"; "native.toolchainSettings.toolsInfo" = "每个工具采用第一个有值的来源,依次为固定版本、项目版本文件、全局默认、自动检测。已安装的版本通过运行该工具自己的版本命令来检查。已启用的版本管理器会报告版本文件所要求的版本。"; diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 347c11bc57..0a08f7393c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -1392,10 +1392,7 @@ final class MacServiceContainer { pluginID: plugin.package.descriptor.id.rawValue, name: plugin.package.manifest.displayName, manifest: plugin.package.descriptor.normalizedManifest, - isEnabled: plugin.isEnabled, - // Short tool IDs need an official catalog entry installed through a verified channel. - verifiedOfficial: OfficialPluginCatalog.trustPolicy(for: plugin.package.descriptor.id) != nil - && [.bundled, .marketplace].contains(plugin.package.installation.origin) + isEnabled: plugin.isEnabled ) } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Resolution.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Resolution.swift index 7cad16014e..1bbba7167c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Resolution.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Resolution.swift @@ -188,7 +188,7 @@ extension MacToolchainDiscovery { let plugins: [[String: Any]] = sources.filter(\.isEnabled).compactMap { source in guard let manifest = try? JSONSerialization.jsonObject(with: source.manifest) as? [String: Any], manifest["toolDefinitions"] != nil || manifest["toolchainProviders"] != nil else { return nil } - return ["manifest": manifest, "verifiedOfficial": source.verifiedOfficial] + return ["manifest": manifest] } return ["plugins": plugins] } diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery.swift index 9d5606060b..b864787132 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery.swift @@ -102,7 +102,7 @@ struct MacToolchainDiscovery: Sendable { guard let manifest = try? JSONSerialization.jsonObject(with: source.manifest) as? [String: Any], manifest["toolDefinitions"] != nil || manifest["toolchainProviders"] != nil else { return nil } - return ["manifest": manifest, "verifiedOfficial": source.verifiedOfficial] + return ["manifest": manifest] } guard !plugins.isEmpty else { return result } let registry: [String: Any] = ["plugins": plugins] diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift index 3e28bf08ae..871d8c7106 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift @@ -9,8 +9,6 @@ struct ToolchainSettingsPluginSource: Equatable, Sendable { let name: String let manifest: Data let isEnabled: Bool - /// Host-verified official origin; Core requires it for short tool definition IDs. - var verifiedOfficial = false } struct ToolchainSettingsSavedPin: Equatable, Sendable { @@ -133,6 +131,21 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { let commands: [String] } + /// Core's registry decides which short tool IDs are contested; Swift never judges a conflict itself. + private static func contestedTools(_ sources: [ToolchainSettingsPluginSource]) -> [String: [String]] { + let input = MacToolchainDiscovery.registry(of: sources) + guard (input["plugins"] as? [Any])?.isEmpty == false, + let registry = MacToolchainDiscovery.callCore("toolchains.registry", input)?["registry"] as? [String: Any], + let conflicts = registry["conflicts"] as? [[String: Any]] + else { return [:] } + var contested: [String: [String]] = [:] + for conflict in conflicts { + guard let tool = conflict["tool"] as? String, let pluginIDs = conflict["pluginIDs"] as? [String] else { continue } + contested[tool] = pluginIDs + } + return contested + } + private static func snapshot( workspace: URL?, sources: [ToolchainSettingsPluginSource], directoryIDs: [String], io: IO ) async throws -> ToolchainSettingsSnapshot { @@ -143,6 +156,7 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { var knownTools: Set = [] var knownPlugins: Set = [] let enabled = sources.filter(\.isEnabled).sorted(by: { $0.pluginID < $1.pluginID }) + let contested = Self.contestedTools(enabled) for source in enabled { try Task.checkCancellation() guard source.manifest.count <= 1_048_576, knownPlugins.insert(source.pluginID).inserted, @@ -150,9 +164,11 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { manifest["id"] as? String == source.pluginID else { throw ToolchainSettingsLoadError.invalidDeclarations } for tool in try contribution(manifest, key: "toolDefinitions", maximum: 32) { - guard let id = text(tool["id"], maximum: 192), let name = text(tool["displayName"], maximum: 128), - knownTools.insert(id).inserted + guard let id = text(tool["id"], maximum: 192), let name = text(tool["displayName"], maximum: 128) else { throw ToolchainSettingsLoadError.invalidDeclarations } + // A tool defined by several enabled plugins belongs to none of them: it is not listed, and the diagnostics explain why. + if contested[id] != nil { continue } + guard knownTools.insert(id).inserted else { throw ToolchainSettingsLoadError.invalidDeclarations } let icon = (tool["icon"] as? [String: Any])?["host"] as? String definitions.append(Definition(id: id, name: name, pluginID: source.pluginID, pluginName: source.name, icon: icon, commands: commands(tool))) @@ -223,6 +239,10 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { guard validDirectory(id) else { throw ToolchainSettingsLoadError.invalidLocal } } var diagnostics: [String] = [] + for (tool, pluginIDs) in contested.sorted(by: { $0.key < $1.key }) { + let names = pluginIDs.map { id in enabled.first { $0.pluginID == id }?.name ?? id }.joined(separator: ", ") + diagnostics.append(L10n.string("Tool \(tool) is defined by more than one enabled plugin (\(names)), so none of these definitions is used. Keep only one of these plugins enabled.")) + } let found = try versionFiles(workspace: workspace, directories: ["."] + directoryIDs, providers: providers, fileExists: io.fileExists, diagnostics: &diagnostics) // Only directories with their own facts are listed, with the ancestors that connect them. diff --git a/macos/Sources/Lithe/Platform/MacOS/ToolchainSettingsDemo.swift b/macos/Sources/Lithe/Platform/MacOS/ToolchainSettingsDemo.swift index e747d3a1dc..9770b48919 100644 --- a/macos/Sources/Lithe/Platform/MacOS/ToolchainSettingsDemo.swift +++ b/macos/Sources/Lithe/Platform/MacOS/ToolchainSettingsDemo.swift @@ -229,7 +229,7 @@ final class ToolchainSettingsDemo: NSObject, NSApplicationDelegate { manifest["requires"] = requires manifest["permissions"] = permissions let data = (try? JSONSerialization.data(withJSONObject: manifest, options: [.sortedKeys])) ?? Data() - return ToolchainSettingsPluginSource(pluginID: id, name: name, manifest: data, isEnabled: true, verifiedOfficial: true) + return ToolchainSettingsPluginSource(pluginID: id, name: name, manifest: data, isEnabled: true) } private static func tool( diff --git a/macos/Tests/LitheTests/MacBuildToolResolverTests.swift b/macos/Tests/LitheTests/MacBuildToolResolverTests.swift index f0a29af41b..ad99243c8e 100644 --- a/macos/Tests/LitheTests/MacBuildToolResolverTests.swift +++ b/macos/Tests/LitheTests/MacBuildToolResolverTests.swift @@ -41,7 +41,7 @@ struct MacBuildToolResolverTests { "displayName": "Fixture", "version": "1.0.0", "pluginAPIVersion": 3, "requires": ["toolDefinitions": 1, "executableResolution": 2], "vendor": ["id": "org.example", "displayName": "Fixture", "signatureRequirement": "publisherPackage"], - "permissions": [], "toolDefinitions": definitions]), isEnabled: true, verifiedOfficial: true) + "permissions": [], "toolDefinitions": definitions]), isEnabled: true) let defined = MacDefinedToolchainResolver() defined.sources = { [source] } let runtime = ProjectRuntimeService(runtimeLocator: CanonicalToolchainRuntimeLocator(), store: ToolchainPreferenceStore()) diff --git a/macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift b/macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift index 76b46acfe2..4a2745a52a 100644 --- a/macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift +++ b/macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift @@ -31,7 +31,7 @@ struct MacNativeBuildProviderIntegrationTests { let sources: [ToolchainSettingsPluginSource] = [ .init(pluginID: "io.github.lithe-idea.maven-build", name: "Maven", manifest: manifest, isEnabled: true), .init(pluginID: "dev.lithe.plugin.java-support", name: "Java", manifest: try Data(contentsOf: javaPackage.appendingPathComponent("plugin.json")), - isEnabled: true, verifiedOfficial: true) + isEnabled: true) ] let before = try Self.inventory(package), javaBefore = try Self.inventory(javaPackage) var pins = ["java": ["home": javaHome, "version": "selected", "provider": "host:manual"]] @@ -109,7 +109,7 @@ struct MacNativeBuildProviderIntegrationTests { let sources: [ToolchainSettingsPluginSource] = [ .init(pluginID: "io.github.lithe-idea.maven-build", name: "Maven", manifest: manifest, isEnabled: true), .init(pluginID: "dev.lithe.plugin.java-support", name: "Java", manifest: try Data(contentsOf: javaPackage.appendingPathComponent("plugin.json")), - isEnabled: true, verifiedOfficial: true) + isEnabled: true) ] let workspace = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-real-default-change-\(UUID().uuidString)") try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) @@ -405,7 +405,7 @@ struct MacNativeBuildProviderIntegrationTests { let javaManifest = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("Plugins/mac/Official/JavaSupport/plugin.json")) let toolSources: [ToolchainSettingsPluginSource] = [ .init(pluginID: descriptor.id.rawValue, name: "Maven", manifest: descriptor.normalizedManifest, isEnabled: true), - .init(pluginID: "dev.lithe.plugin.java-support", name: "Java", manifest: javaManifest, isEnabled: true, verifiedOfficial: true) + .init(pluginID: "dev.lithe.plugin.java-support", name: "Java", manifest: javaManifest, isEnabled: true) ] defined.sources = { toolSources } let projectRuntime = ProjectRuntimeService(runtimeLocator: CanonicalToolchainRuntimeLocator(), store: NativeDefinedToolPreferences()) diff --git a/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift b/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift index 8a22da7e8c..2a2984bc3e 100644 --- a/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift +++ b/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift @@ -190,8 +190,7 @@ struct MacToolchainDiscoveryTests { } private static func source(complete: Bool = true) -> ToolchainSettingsPluginSource { - .init(pluginID: pluginID, name: "Discovery fixture", manifest: manifest(complete: complete), isEnabled: true, - verifiedOfficial: true) + .init(pluginID: pluginID, name: "Discovery fixture", manifest: manifest(complete: complete), isEnabled: true) } @Test func installedToolDeclarationsPassTheProductionHostAdmission() throws { @@ -212,7 +211,7 @@ struct MacToolchainDiscoveryTests { definitions[0]["launch"] = ["${executable}", "--fixture", "${arguments}"] manifest["toolDefinitions"] = definitions let source = ToolchainSettingsPluginSource(pluginID: Self.pluginID, name: "Fixture", - manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true, verifiedOfficial: true) + manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true) let local: ToolingJSONValue = .object(["version": .number(1), "toolchains": .object([:])]) await #expect(throws: BuildToolResolutionError.self) { try await discovery(root: root, probes: probes).resolve( @@ -233,7 +232,7 @@ struct MacToolchainDiscoveryTests { definitions[0]["exports"] = [["name": "COMPILER_HOME", "value": "${home}"]] manifest["toolDefinitions"] = definitions let source = ToolchainSettingsPluginSource(pluginID: Self.pluginID, name: "Runtime", - manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true, verifiedOfficial: true) + manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true) let selected = try await discovery(root: root, probes: probes).resolve( .init(workspace: root.url("workspace"), sources: [source], environment: [:]), tool: "compiler", directory: ".", minimumVersion: "17", local: .object(["version": .number(1), "toolchains": .object([:])]), @@ -308,6 +307,75 @@ struct MacToolchainDiscoveryTests { #expect(probes.calls.isEmpty) } + /// A definition for `tool` whose installations sit in `home//*.kit/Contents/Home`, so two + /// tools of one fixture never see each other's installations. + private static func scannedDefinition(tool: String, folder: String) -> [String: Any] { + [ + "id": tool, "displayName": "Fixture \(tool)", "icon": ["host": "plugin"], + "executableLayouts": [["path": "bin/\(tool)"]], + "executables": [ + ["id": "shell", "kind": "command", "command": tool], + ["id": "detected", "kind": "toolchain", "scope": "installed"] + ], + "version": ["arguments": ["--version"], "pattern": #"\#(tool) (\d+(?:\.\d+)*)"#], + "homeLayouts": [["match": "*.kit", "home": "Contents/Home"]], + "discovery": [["root": ["kind": "home"], "pattern": "\(folder)/*"]], + "commands": [tool], "wrappers": [], "exports": [], + "pathPrepend": ["${home}/bin"], "launch": ["${executable}", "${arguments}"] + ] + } + + /// An enabled plugin with its own ID that defines the given tools by their short IDs. + private static func definingPlugin(id: String, name: String, tools: [(id: String, folder: String)]) throws + -> ToolchainSettingsPluginSource { + let manifest: [String: Any] = [ + "schemaVersion": 2, "id": id, "displayName": name, "version": "1.0.0", "pluginAPIVersion": 3, + "requires": ["toolDefinitions": 1, "executableResolution": 2], + "vendor": ["id": "org.example", "displayName": "Fixture publisher", "signatureRequirement": "publisherPackage"], + "permissions": [], "toolDefinitions": tools.map { scannedDefinition(tool: $0.id, folder: $0.folder) } + ] + return .init(pluginID: id, name: name, manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true) + } + + /// A short tool ID defined by two enabled plugins belongs to neither, so install order cannot + /// decide the owner. Only that tool leaves discovery and resolution; every other tool still works. + @Test func aShortToolDefinedByTwoEnabledPluginsIsNeitherDiscoveredNorResolved() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let contested = try root.installation("home/Kits/contested.kit/Contents/Home") + let linter = try root.installation("home/Linters/lint.kit/Contents/Home", executable: "linter") + let probes = DiscoveryProbes(outputs: [contested + "/bin/compiler": "compiler 21", linter + "/bin/linter": "linter 3.2"]) + let alpha = try Self.definingPlugin(id: "io.example.alpha", name: "Alpha", + tools: [(id: "compiler", folder: "Kits"), (id: "linter", folder: "Linters")]) + let beta = try Self.definingPlugin(id: "io.example.beta", name: "Beta", tools: [(id: "compiler", folder: "Kits")]) + let request = MacToolchainDiscovery.Request(workspace: root.url("workspace"), sources: [alpha, beta], environment: [:]) + let resolver = discovery(root: root, probes: probes) + + let result = await resolver.discover(request) + #expect(result.detections["compiler"] == nil) + #expect(result.resolutions["."]?["compiler"] == nil) + let detection = try #require(result.detections["linter"]) + #expect(detection.installations.map(\.home) == [linter] && detection.selected == linter) + #expect(result.resolutions["."]?["linter"] == .init(layer: .automatic, outcome: .resolved( + home: linter, executable: linter + "/bin/linter", version: "3.2", provider: "host:system"))) + #expect(result.unchecked.isEmpty && result.unresolved.isEmpty && result.diagnostics.isEmpty) + // The contested tool's installation is never probed, only the tool nobody else defines. + #expect(probes.calls == [.init(executable: linter + "/bin/linter", arguments: ["--version"])]) + + let local: ToolingJSONValue = .object(["version": .number(1), "toolchains": .object([:])]) + let automatic = try await resolver.resolve(request, tool: "compiler", directory: ".", minimumVersion: nil, + local: local, projectCodeAllowed: true) + #expect(automatic == nil) + // A fixed home cannot give the contested tool an owner either. + let fixed = try await resolver.resolve(request, tool: "compiler", directory: ".", minimumVersion: nil, + local: local, projectCodeAllowed: true, fixed: .installation(home: contested)) + #expect(fixed == nil) + let other = try await resolver.resolve(request, tool: "linter", directory: ".", minimumVersion: nil, + local: local, projectCodeAllowed: true, fixed: .installation(home: linter)) + #expect(other?.executable.path == linter + "/bin/linter" && other?.version == "3.2") + #expect(!probes.calls.contains { $0.executable == contested + "/bin/compiler" }) + } + /// The definition plugin as its own provider, as a version-manager plugin declares one. Its /// definition gains the project and global layers, where provider statements decide. private static func providerSource(providers: [String] = ["kits"]) throws -> ToolchainSettingsPluginSource { @@ -326,7 +394,7 @@ struct MacToolchainDiscoveryTests { } manifest["backend"] = ["launch": ["kind": "packaged", "path": "bin/kits"]] return .init(pluginID: pluginID, name: "Discovery fixture", - manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true, verifiedOfficial: true) + manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true) } /// A provider reports an alias link; Core keeps the canonical home, the project @@ -519,7 +587,7 @@ struct MacToolchainDiscoveryTests { /// The settings preview scans this Mac for real, so its sample manifests must pass Core's admission. @MainActor @Test func previewSamplesAreAdmittedByCore() throws { let plugins: [[String: Any]] = try ToolchainSettingsDemo.samplePlugins.map { source in - ["manifest": try JSONSerialization.jsonObject(with: source.manifest), "verifiedOfficial": source.verifiedOfficial] + ["manifest": try JSONSerialization.jsonObject(with: source.manifest)] } let plan = MacToolchainDiscovery.callCore("toolchains.discoveryPlan", ["registry": ["plugins": plugins], "platform": "macos"])?["plan"] @@ -622,7 +690,7 @@ struct MacToolchainDiscoveryTests { definitions[0]["wrappers"] = [["path": "compilerw"]] manifest["toolDefinitions"] = definitions let source = ToolchainSettingsPluginSource(pluginID: Self.pluginID, name: "Fixture", - manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true, verifiedOfficial: true) + manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true) let probes = DiscoveryProbes(outputs: [wrapper.path: "compiler 21"]) let discovery = discovery(root: root, probes: probes) let request = MacToolchainDiscovery.Request(workspace: root.url("workspace"), sources: [source], environment: [:]) @@ -778,9 +846,9 @@ private struct DiscoveryFixtureRoot { func url(_ relative: String) -> URL { URL(fileURLWithPath: path + "/" + relative) } - /// A home with an executable `bin/compiler`; returns the home path. - func installation(_ relative: String) throws -> String { - let executable = url(relative + "/bin/compiler") + /// A home with an executable `bin/`; returns the home path. + func installation(_ relative: String, executable name: String = "compiler") throws -> String { + let executable = url(relative + "/bin/" + name) try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) try Data("#!/bin/sh\n".utf8).write(to: executable) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) diff --git a/macos/Tests/LitheTests/ToolchainSettingsLoaderTests.swift b/macos/Tests/LitheTests/ToolchainSettingsLoaderTests.swift index 584c4875ea..48b1437782 100644 --- a/macos/Tests/LitheTests/ToolchainSettingsLoaderTests.swift +++ b/macos/Tests/LitheTests/ToolchainSettingsLoaderTests.swift @@ -26,6 +26,36 @@ struct ToolchainSettingsLoaderTests { manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: enabled) } + /// A complete manifest Core admits, unlike `source()`: the loader asks Core's registry which short tool + /// IDs are contested, and Core refuses an incomplete manifest. A provider is added when `providerTools` is set. + private func definingSource( + pluginID: String, name: String, tools: [String], providerTools: [String] = [] + ) throws -> ToolchainSettingsPluginSource { + var manifest: [String: Any] = [ + "schemaVersion": 2, "id": pluginID, "displayName": name, "version": "1.0.0", "pluginAPIVersion": 3, + "requires": ["toolDefinitions": 1, "executableResolution": 2], + "vendor": ["id": "org.example", "displayName": "Fixture publisher", "signatureRequirement": "publisherPackage"], + "permissions": [], + "toolDefinitions": tools.map { tool -> [String: Any] in + ["id": tool, "displayName": "Fixture \(tool)", "icon": ["host": "plugin"], + "executableLayouts": [["path": "bin/\(tool)"]], + "executables": [["id": "shell", "kind": "command", "command": tool]], + "version": ["arguments": ["--version"], "pattern": #"\#(tool) (\d+(?:\.\d+)*)"#], + "commands": [tool], "launch": ["${executable}", "${arguments}"]] + } + ] + if !providerTools.isEmpty { + manifest["requires"] = ["toolDefinitions": 1, "executableResolution": 2, "toolchainProviders": 1, + "backend": 2, "serverProcess": 1] + manifest["permissions"] = ["backend.launch", "toolchains.provide"] + manifest["toolchainProviders"] = [["id": "manager", "displayName": "Fixture manager", "tools": providerTools, + "watchFiles": [["pattern": ".fixture-version", "reader": "native"]]] as [String: Any]] + manifest["backend"] = ["launch": ["kind": "packaged", "path": "bin/kits"]] + } + return .init(pluginID: pluginID, name: name, manifest: try JSONSerialization.data(withJSONObject: manifest), + isEnabled: true) + } + private func local(_ pins: [String: Any]) throws -> Data { try JSONSerialization.data(withJSONObject: ["version": 1, "toolchains": [:], "pins": pins]) } @@ -365,6 +395,8 @@ struct ToolchainSettingsLoaderTests { } } + /// `source()` manifests are incomplete, so Core's registry refuses them and names no conflict. The loader + /// then keeps its own check: a repeated tool ID or plugin ID it cannot attribute is still refused. @Test func duplicateToolOwnersAndMalformedEnabledDeclarationsAreRefused() async throws { let (first, second) = (try source(), try source(pluginID: "dev.lithe.fixture.other")) let malformed = ToolchainSettingsPluginSource(pluginID: "invalid", name: "Invalid", manifest: Data("invalid".utf8), isEnabled: true) @@ -375,6 +407,32 @@ struct ToolchainSettingsLoaderTests { } } + /// A short tool ID defined by two enabled plugins belongs to neither. The page still loads: the + /// contested tool has no row and no provider capsule, other tools stay listed, and the diagnostics + /// name every plugin involved, in plugin ID order. + @Test func aToolDefinedByTwoEnabledPluginsIsUnlistedAndExplainedInsteadOfFailingThePage() async throws { + let alpha = try definingSource(pluginID: "io.example.alpha", name: "Alpha", tools: ["demo", "extra"], + providerTools: ["demo", "extra"]) + let beta = try definingSource(pluginID: "io.example.beta", name: "Beta", tools: ["demo"]) + let requests = ToolchainDiscoveryRequests() + let snapshot = try await loader(sources: { [beta, alpha] }, discover: { request in + requests.record(request) + return .init() + }).load(workspace: workspace) + #expect(snapshot.tools.map(\.id) == ["extra"]) + #expect(snapshot.tools.first?.pluginID == alpha.pluginID && snapshot.tools.first?.pluginName == "Alpha") + #expect(snapshot.providers.map(\.tools) == [["extra"]]) + #expect(snapshot.diagnostics == [L10n.string( + "Tool \("demo") is defined by more than one enabled plugin (\("Alpha, Beta")), so none of these definitions is used. Keep only one of these plugins enabled.")]) + // Discovery still receives every enabled source; Core leaves the contested tool out of its own plan. + #expect(requests.values.count == 1) + #expect(requests.values.first?.sources.map(\.pluginID) == [alpha.pluginID, beta.pluginID]) + + // Without a workspace there is no page to explain it on, yet the capsule list stays consistent. + let listing = try await loader(sources: { [alpha, beta] }).load(workspace: nil) + #expect(listing.providers.map(\.tools) == [["extra"]]) + } + @Test func installationOnlyProvidersAreListedWithoutCheckingVersionFiles() async throws { let source = try source(watchFiles: nil) let files = ToolchainLoaderFiles() diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 1c289934e6..e7b7fc6665 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -107,8 +107,10 @@ const MAX_REQUIRED_TOOLS: usize = 32; /// Host integration that feeds JDT LS's project model, virtual class files, /// and Java test and debug support from the files a plugin declares. const HOST_INTEGRATION_JDT: &str = "jdt"; -/// Native host integrations a language server may ask for. Each one is code -/// in the host, so only plugins in the official namespace may declare one. +/// Native host integrations a language server may ask for. Any plugin may +/// declare one: each is host code that only reads files from the declaring +/// plugin's own package and hands them to a process that plugin starts, so it +/// grants no extra reach. A name the host does not implement is still rejected. const KNOWN_HOST_INTEGRATIONS: &[&str] = &[HOST_INTEGRATION_JDT]; /// Host capability: starting a program of the language under a debugger. const HOST_CAPABILITY_DEBUG: &str = "debug"; @@ -491,7 +493,7 @@ struct LanguageServerDeclaration { #[serde(flatten)] process: ServerProcess, /// Native host integration this server needs, with the package files it - /// consumes. API level 2, official plugins only. + /// consumes. API level 2. #[serde(default, skip_serializing_if = "Option::is_none")] host_integration: Option, /// Settings the server reads through `workspace/configuration` and @@ -1055,7 +1057,6 @@ impl DeclarativePluginManifest { &field, self.plugin_api_version, server_version, - is_official_namespace(&self.id), &declared_settings, runtime_tools, )?; @@ -1970,9 +1971,10 @@ impl DeclarativePluginManifest { } /// Whether the plugin declares a definition that `tool` names. A - /// definition is addressed by its short ID (verified official plugins) or - /// as `:`; which of the two the host honors is its registry's - /// decision, so both spellings count as a declaration here. + /// definition is addressed by its short ID or as `:`; both + /// spellings count as a declaration here. Whether the host adopts the + /// short ID is its registry's decision: when several enabled plugins define + /// the same short ID, it belongs to none of them. pub(crate) fn declares_tool(&self, tool: &str) -> bool { // `Some(local)` when `tool` is spelled `:`. let local_name = tool @@ -2402,7 +2404,6 @@ impl LanguageServerDeclaration { field: &str, api_version: u32, capability_version: u32, - official: bool, declared_settings: &DeclaredSettings, runtime_tools: RuntimeTools<'_>, ) -> Validation<()> { @@ -2455,7 +2456,7 @@ impl LanguageServerDeclaration { } if let Some(integration) = &self.host_integration { let templated = api_version >= LAUNCH_TEMPLATES_API_VERSION; - self.validate_host_integration(integration, field, templated, official)?; + self.validate_host_integration(integration, field, templated)?; } Ok(()) } @@ -2465,7 +2466,6 @@ impl LanguageServerDeclaration { integration: &HostIntegrationDeclaration, field: &str, templated: bool, - official: bool, ) -> Validation<()> { let field = format!("{field}.hostIntegration"); if !templated { @@ -2478,15 +2478,6 @@ impl LanguageServerDeclaration { format!("The host has no integration named {}.", integration.id), )); } - // An integration runs trusted host code against the plugin's files, - // so a community package must not be able to request one. - if !official { - return Err(PluginRejection::at( - "plugin.integration.unsupported", - format!("{field}.id"), - "Host integrations are reserved to official plugins.", - )); - } // (capability, resources the host code behind it reads) let capabilities: &[(&str, &[&str])] = match integration.id.as_str() { HOST_INTEGRATION_JDT => &[ diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 2c3a167540..9b63efe5b8 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -326,6 +326,23 @@ fn host_integration_capabilities_are_normalized_and_checked_against_the_integrat assert_eq!(rejected_field(owned_twice), field); } +/// A host integration only reads files from the declaring plugin's own package +/// and feeds the process that plugin starts, so it adds no reach and any +/// namespace may declare one. +#[test] +fn community_plugins_may_declare_a_host_integration() { + let mut manifest = fixture("language-host-integration.plugin.json"); + let integration = manifest["languageServers"][0]["hostIntegration"]["id"].clone(); + manifest["id"] = json!("io.example.host-integration"); + manifest["vendor"]["signatureRequirement"] = json!("none"); + let outcome = validate(manifest); + assert_eq!(outcome["status"], "ok", "{outcome}"); + assert_eq!( + outcome["plugin"]["languageServers"][0]["hostIntegration"]["id"], + integration + ); +} + #[test] fn command_plan_fixtures_match_golden_expansions() { let fixtures: Value = serde_json::from_str(COMMAND_PLANS).unwrap(); diff --git a/rust/lithe-core/src/tests/plugin_run_debug.rs b/rust/lithe-core/src/tests/plugin_run_debug.rs index 54288b17ad..84173d8330 100644 --- a/rust/lithe-core/src/tests/plugin_run_debug.rs +++ b/rust/lithe-core/src/tests/plugin_run_debug.rs @@ -383,7 +383,7 @@ fn output_link_matchers_alone_are_a_complete_plugin() { fn instantiate(tool_definition: Value, parts: Value) -> Value { let mut input = json!({ - "registry": { "plugins": [{ "manifest": tool_definition, "verifiedOfficial": true }] }, + "registry": { "plugins": [{ "manifest": tool_definition }] }, "tool": "interpreter", "executable": "/opt/interpreter/bin/interpreter", }); @@ -449,7 +449,6 @@ fn launching_a_tool_nobody_defined_is_refused() { let outcome = instantiate_launch_command(json!({ "registry": { "plugins": [{ "manifest": parse(TOOL_REFERENCE_MANIFEST), - "verifiedOfficial": true, }] }, "tool": "missing-tool", "executable": "/opt/missing/bin/tool", diff --git a/rust/lithe-core/src/tests/toolchain_definitions.rs b/rust/lithe-core/src/tests/toolchain_definitions.rs index e5ca909bd1..b863e8fcee 100644 --- a/rust/lithe-core/src/tests/toolchain_definitions.rs +++ b/rust/lithe-core/src/tests/toolchain_definitions.rs @@ -34,13 +34,13 @@ fn tool_definitions_are_complete_contributions_without_languages_or_backend() { definition("dev.lithe.fixture.compiler:compiler"), ); assert!(validate_manifest_value(plugin.clone()).is_ok()); - let result = - registry(&json!({"plugins":[{"manifest":plugin,"verifiedOfficial":false}]})).unwrap(); + let result = registry(&json!({"plugins":[{"manifest":plugin}]})).unwrap(); assert_eq!( result["definitions"][0]["definition"]["id"], "dev.lithe.fixture.compiler:compiler" ); assert_eq!(result["providers"], json!([])); + assert_eq!(result["conflicts"], json!([])); } #[test] @@ -56,20 +56,49 @@ fn tool_definitions_second_tool_needs_only_plugin_data() { } } +/// Every plugin is a community plugin, so a short ID needs no host-attested origin. #[test] -fn registry_short_ids_require_independent_origin_not_vendor_or_namespace() { +fn registry_accepts_short_ids_from_any_enabled_plugin() { let plugin = manifest("dev.lithe.fixture.compiler", definition("compiler")); assert!(validate_manifest_value(plugin.clone()).is_ok()); - assert!(registry(&json!({"plugins":[{"manifest":plugin,"verifiedOfficial":false}]})).is_err()); - assert!(registry(&json!({"plugins":[{"manifest":plugin,"verifiedOfficial":true}]})).is_ok()); - assert!(registry(&json!({"plugins":[{"manifest":plugin}]})).is_err()); + let result = registry(&json!({"plugins":[{"manifest":plugin}]})).unwrap(); + assert_eq!(result["definitions"][0]["definition"]["id"], "compiler"); + assert_eq!( + result["definitions"][0]["pluginID"], + "dev.lithe.fixture.compiler" + ); + assert_eq!(result["conflicts"], json!([])); + // The retired host-supplied origin flag is an unknown field, not silently ignored. + assert!(registry(&json!({"plugins":[{"manifest":plugin,"verifiedOfficial":true}]})).is_err()); } +/// Install order and plugin ID must not pick an owner, so a contested ID has none. #[test] -fn registry_never_replaces_existing_tool_owner() { +fn registry_leaves_a_contested_tool_id_unowned_and_registers_the_rest() { let first = manifest("dev.lithe.fixture.first", definition("compiler")); let second = manifest("dev.lithe.fixture.second", definition("compiler")); - assert!(registry(&json!({"plugins":[{"manifest":first,"verifiedOfficial":true},{"manifest":second,"verifiedOfficial":true}]})).is_err()); + let mut third = manifest("dev.lithe.fixture.third", definition("renderer")); + third["toolDefinitions"] + .as_array_mut() + .unwrap() + .push(definition("dev.lithe.fixture.third:private")); + for plugins in [ + json!([{"manifest":first},{"manifest":second},{"manifest":third}]), + json!([{"manifest":third},{"manifest":second},{"manifest":first}]), + ] { + let result = registry(&json!({"plugins":plugins})).unwrap(); + let ids: Vec<_> = result["definitions"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["definition"]["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, ["dev.lithe.fixture.third:private", "renderer"]); + assert_eq!( + result["conflicts"], + json!([{"tool":"compiler","pluginIDs":["dev.lithe.fixture.first","dev.lithe.fixture.second"]}]) + ); + } assert!(validate_manifest_value(manifest( "dev.lithe.fixture.first", definition("dev.lithe.fixture.second:compiler") @@ -77,6 +106,35 @@ fn registry_never_replaces_existing_tool_owner() { .is_err()); } +/// A provider that lists a contested tool still registers; only the definition is withheld. +#[test] +fn registry_keeps_providers_that_name_a_contested_tool() { + let first = manifest("dev.lithe.fixture.first", definition("compiler")); + let second = manifest("dev.lithe.fixture.second", definition("compiler")); + let provider = json!({"schemaVersion":2,"id":"dev.lithe.fixture.provider","displayName":"Provider","version":"1.0.0", + "pluginAPIVersion":3,"requires":{"toolchainProviders":1,"backend":2,"serverProcess":1}, + "vendor":{"id":"org.example","displayName":"Fixture","signatureRequirement":"publisherPackage"}, + "permissions":["backend.launch","toolchains.provide"], + "toolchainProviders":[{"id":"installs","displayName":"Installs","tools":["compiler"]}], + "backend":{"launch":{"kind":"packaged","path":"bin/provider"}}}); + let plugins = json!([{"manifest":first},{"manifest":second},{"manifest":provider}]); + let result = registry(&json!({"plugins":plugins})).unwrap(); + assert_eq!(result["definitions"], json!([])); + assert_eq!( + result["providers"][0]["pluginID"], + "dev.lithe.fixture.provider" + ); + assert_eq!(result["conflicts"][0]["tool"], "compiler"); +} + +/// The same plugin ID twice makes the enabled set ambiguous, so the whole batch is refused. +#[test] +fn registry_rejects_duplicate_plugin_ids() { + let first = manifest("dev.lithe.fixture.compiler", definition("compiler")); + let second = manifest("dev.lithe.fixture.compiler", definition("renderer")); + assert!(registry(&json!({"plugins":[{"manifest":first},{"manifest":second}]})).is_err()); +} + #[test] fn definition_nested_objects_fail_closed_and_enforce_safe_exports() { let base = definition("dev.lithe.fixture.compiler:compiler"); @@ -143,8 +201,7 @@ fn definitions_reuse_shared_candidate_graph_ordering() { json!({"id":"alternative","kind":"command","command":"compiler-alt","insertAfter":"shell"}), ); let plugin = manifest("dev.lithe.fixture.compiler", value); - let result = - registry(&json!({"plugins":[{"manifest":plugin,"verifiedOfficial":false}]})).unwrap(); + let result = registry(&json!({"plugins":[{"manifest":plugin}]})).unwrap(); assert_eq!( result["definitions"][0]["definition"]["executables"][4]["id"], "shell" @@ -212,8 +269,7 @@ fn a_definition_plugin_may_provide_installations_of_its_own_tool() { json!([{"id":"installs","displayName":"Installs","tools":[format!("{id}:compiler")]}]); plugin["backend"] = json!({"launch":{"kind":"packaged","path":"bin/provider"}}); assert!(validate_manifest_value(plugin.clone()).is_ok()); - let result = - registry(&json!({"plugins":[{"manifest":plugin,"verifiedOfficial":false}]})).unwrap(); + let result = registry(&json!({"plugins":[{"manifest":plugin}]})).unwrap(); assert_eq!(result["definitions"][0]["pluginID"], id); assert_eq!(result["providers"][0]["pluginID"], id); } diff --git a/rust/lithe-core/src/tests/toolchain_queries.rs b/rust/lithe-core/src/tests/toolchain_queries.rs index 09f05bad62..505cd573f2 100644 --- a/rust/lithe-core/src/tests/toolchain_queries.rs +++ b/rust/lithe-core/src/tests/toolchain_queries.rs @@ -28,7 +28,7 @@ fn fact(path: &str, kind: &str, executable: bool) -> Value { } fn input() -> Value { - json!({"registry":{"plugins":[{"manifest":manifest(&contract("java")),"verifiedOfficial":true},{"manifest":manifest(&contract("mise")),"verifiedOfficial":false}]}, + json!({"registry":{"plugins":[{"manifest":manifest(&contract("java"))},{"manifest":manifest(&contract("mise"))}]}, "pluginID":"dev.lithe.fixture.mise","request":query_request(),"response":query_response(),"currentGeneration":1,"platform":"macos", "filesystem":[fact(WORKSPACE,"directory",false),fact(HOME,"directory",false),fact(&format!("{HOME}/bin/java"),"file",true),fact(&format!("{WORKSPACE}/mise.toml"),"file",false)]}) } diff --git a/rust/lithe-core/src/tests/toolchain_resolution.rs b/rust/lithe-core/src/tests/toolchain_resolution.rs index ca442b8a44..7ac94333fe 100644 --- a/rust/lithe-core/src/tests/toolchain_resolution.rs +++ b/rust/lithe-core/src/tests/toolchain_resolution.rs @@ -37,9 +37,9 @@ fn fact(path: &str, kind: &str, executable: bool) -> Value { json!({"path":path,"canonicalPath":path,"kind":kind,"executable":executable}) } -/// Starts with a verified definition and provider but no selected installation. +/// Starts with an enabled definition and provider but no selected installation. fn input() -> Value { - json!({"registry":{"plugins":[{"manifest":manifest(&contract("java")),"verifiedOfficial":true},{"manifest":manifest(&contract("mise")),"verifiedOfficial":false}]}, + json!({"registry":{"plugins":[{"manifest":manifest(&contract("java"))},{"manifest":manifest(&contract("mise"))}]}, "operationID":"resolve-1","generation":1,"context":{"contextID":"context-1","generation":1},"tool":"java","directory":".","platform":"macos","workspaceRoot":WORKSPACE, "filesystem":[fact(WORKSPACE,"directory",false)],"probes":[],"queries":[],"systemInstallations":[],"environment":{},"commandHomes":{}}) } @@ -388,7 +388,7 @@ fn toolchain_resolution_conflicting_global_providers_follow_shell_not_plugin_id( value["registry"]["plugins"] .as_array_mut() .unwrap() - .push(json!({"manifest":second,"verifiedOfficial":false})); + .push(json!({"manifest":second})); for source in [ "/opt/lithe-fixtures/global/mise.toml", "/opt/lithe-fixtures/alternative/mise.toml", @@ -890,6 +890,33 @@ fn toolchain_resolution_disabled_definition_cannot_reactivate_a_saved_pin() { assert_eq!(value["local"], before); } +/// A short ID claimed by two enabled plugins has no owner, so every consumer sees no definition. +#[test] +fn toolchain_resolution_contested_tool_id_is_unowned_for_every_consumer() { + let mut value = input(); + let tool = value["tool"].as_str().unwrap().to_owned(); + installation(&mut value, HOME, "21.0.4"); + value["local"] = local_pins(); + value["commandHomes"][tool.as_str()] = json!(HOME); + // The rival is a copy of the defining plugin under another ID. + let mut rival = value["registry"]["plugins"][0]["manifest"].clone(); + rival["id"] = json!("io.example.rival"); + value["registry"]["plugins"] + .as_array_mut() + .unwrap() + .push(json!({"manifest":rival})); + let plan = + discovery_plan_command(json!({"registry":value["registry"],"platform":"macos"})).unwrap(); + assert_eq!(plan["status"], "ok", "{plan}"); + assert_eq!(plan["plan"]["tools"], json!([])); + let request = json!({"registry":value["registry"],"tool":tool,"output":"21.0.4"}); + let checked = check_version_command(request).unwrap(); + assert_eq!(checked["status"], "rejected", "{checked}"); + let result = resolve(&value); + assert_eq!(result["outcome"]["kind"], "skip"); + assert_eq!(result["outcome"]["reason"]["code"], "definitionUnavailable"); +} + #[test] fn toolchain_resolution_nearest_unmet_project_source_masks_parent_requirement() { let mut value = input(); diff --git a/rust/lithe-core/src/toolchain/definitions.rs b/rust/lithe-core/src/toolchain/definitions.rs index c5d443c373..cc68596179 100644 --- a/rust/lithe-core/src/toolchain/definitions.rs +++ b/rust/lithe-core/src/toolchain/definitions.rs @@ -1,11 +1,11 @@ -//! Validates plugin-owned tool policies and constructs an origin-checked registry. +//! Validates plugin-owned tool policies and constructs the enabled-plugin registry. //! Filesystem evidence and native execution remain host-owned responsibilities. use crate::plugins::declarative::{invalid, validate_manifest_value, UIIcon, Validation}; use crate::plugins::executables::{ordered_indices, ExecutableCandidate}; use regex::Regex; use serde_json::{json, Map, Value}; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; /// Validates a short tool ID or a plugin-qualified tool ID, without granting ownership. pub(crate) fn tool_id(value: &str) -> bool { @@ -722,28 +722,21 @@ pub(crate) fn validate_declarations( Ok(()) } -/// Revalidates complete enabled manifests and host-attested origin before registration. -/// A conflicting batch is rejected atomically; an existing definition is never replaced. +/// Revalidates the complete manifests of enabled plugins and builds the tool registry. +/// A tool ID declared by several plugins belongs to none of them: install order and plugin +/// ID must not decide ownership, or a hostile plugin could claim an ID before its rightful +/// owner. Those definitions are left out and reported in `conflicts`; every other definition +/// still registers. Duplicate plugin IDs make the enabled set ambiguous, so they reject the +/// whole batch. pub(crate) fn registry(input: &Value) -> Validation { object(input, &["plugins"], &["plugins"], "registry")?; let plugins = list(&input["plugins"], 0, 128, "plugins")?; let mut definitions = Vec::new(); let mut providers = Vec::new(); - let mut tool_ids = BTreeSet::new(); + let mut owners: BTreeMap> = BTreeMap::new(); let mut plugin_ids = BTreeSet::new(); for entry in plugins { - object( - entry, - &["manifest", "verifiedOfficial"], - &["manifest", "verifiedOfficial"], - "plugins", - )?; - let official = entry["verifiedOfficial"].as_bool().ok_or_else(|| { - invalid( - "verifiedOfficial", - "Verified origin is host-supplied boolean evidence.", - ) - })?; + object(entry, &["manifest"], &["manifest"], "plugins")?; let manifest = validate_manifest_value(entry["manifest"].clone())?; let manifest = serde_json::to_value(manifest) .map_err(|_| invalid("manifest", "The validated manifest cannot be encoded."))?; @@ -756,9 +749,10 @@ pub(crate) fn registry(input: &Value) -> Validation { } for definition in optional_list(&manifest, "toolDefinitions", 32)? { let id = text(&definition["id"], 192, "toolDefinitions.id")?; - if (!id.contains(':') && !official) || !tool_ids.insert(id.to_owned()) { - return Err(invalid("toolDefinitions.id", "A short ID needs verified official provenance and no ID may replace an existing owner.")); - } + owners + .entry(id.to_owned()) + .or_default() + .insert(plugin_id.to_owned()); let mut normalized = definition.clone(); let indices = candidates(definition)?; normalized["executables"] = json!(indices @@ -777,6 +771,18 @@ pub(crate) fn registry(input: &Value) -> Validation { )); } } + // A qualified ID starts with its unique plugin ID, so only short IDs can be claimed twice. + let conflicts: Vec = owners + .iter() + .filter(|(_, claimants)| claimants.len() > 1) + .map(|(tool, claimants)| json!({"tool":tool,"pluginIDs":claimants})) + .collect(); + definitions.retain(|entry| { + entry["definition"]["id"] + .as_str() + .and_then(|id| owners.get(id)) + .is_some_and(|claimants| claimants.len() == 1) + }); definitions.sort_by_key(|entry| { ( entry["definition"]["id"].as_str().unwrap_or("").to_owned(), @@ -789,5 +795,5 @@ pub(crate) fn registry(input: &Value) -> Validation { entry["provider"]["id"].as_str().unwrap_or("").to_owned(), ) }); - Ok(json!({"definitions":definitions,"providers":providers})) + Ok(json!({"definitions":definitions,"providers":providers,"conflicts":conflicts})) } diff --git a/rust/lithe-core/src/toolchain/mod.rs b/rust/lithe-core/src/toolchain/mod.rs index ee8a575f48..3a9b8eec48 100644 --- a/rust/lithe-core/src/toolchain/mod.rs +++ b/rust/lithe-core/src/toolchain/mod.rs @@ -33,7 +33,8 @@ fn command( }) } -/// Registers definitions using independently verified host package identities. +/// Registers the definitions of enabled plugins; a tool ID declared by several plugins +/// belongs to none of them and is reported in `conflicts` instead. pub fn registry_command(input: Value) -> Result { command(input, definitions::registry, "registry") } diff --git a/rust/lithe-core/src/toolchain/query.rs b/rust/lithe-core/src/toolchain/query.rs index b2c55f4a88..532e96d936 100644 --- a/rust/lithe-core/src/toolchain/query.rs +++ b/rust/lithe-core/src/toolchain/query.rs @@ -577,7 +577,7 @@ pub(crate) fn validate_installation( checked_installation(home, definition, &facts, &roots, layout_path) } -/// Decodes one provider reply using only verified registry and native facts. +/// Decodes one provider reply using only the revalidated registry and native facts. /// No filesystem, process, environment or JSON-RPC transport is accessed here. pub(crate) fn decode(input: &Value) -> Validation { object_keys( @@ -755,7 +755,7 @@ pub(crate) fn decode(input: &Value) -> Validation { reject( "plugin.toolchain.permissionDenied", "pluginID", - "The enabled provider is not in the verified registry.", + "The enabled provider is not in the revalidated registry.", ) })?; let provider = &provider["provider"]; diff --git a/scripts/architecture/language-specifics-baseline.json b/scripts/architecture/language-specifics-baseline.json index caddb944a8..900ae02613 100644 --- a/scripts/architecture/language-specifics-baseline.json +++ b/scripts/architecture/language-specifics-baseline.json @@ -326,7 +326,7 @@ "shared/contracts/plugins/v1/fixtures/languages/command-plans.json": {"words":80}, "shared/contracts/plugins/v1/fixtures/languages/language-host-integration.plugin.json": {"words":17}, "shared/contracts/plugins/v1/fixtures/languages/runtime-versions.json": {"words":19}, - "shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json": {"words":22}, + "shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json": {"words":21}, "shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json": {"words":3}, "shared/contracts/plugins/v1/fixtures/package-trust/publisher-bundled.plugin.json": {"words":2}, "shared/contracts/plugins/v1/fixtures/project-generators/project-generator.plugin.json": {"words":13}, diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 32459973f0..4581af564d 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -14,7 +14,7 @@ This directory is the cross-platform source of truth: | `native-ui.schema.json` | JSON shape of native UI documents rendered by the host | | `plugin-index.schema.json` | JSON shape of a marketplace source index | | `fixtures/` | Valid example manifests and golden cases, one folder per contract area (below). Manifests are named after the capability they exercise rather than a published plugin. Case files name their base manifest by file name, so file names are unique across the folders | -| `toolchains/README.md` | Implemented Core foundation: plugin definitions/provider declarations, verified-origin registry, reply validation, directory pins, snapshot resolution, version matching and discovery plans; macOS uses it for its read-only settings pages and for Build and run tools, Windows is not connected | +| `toolchains/README.md` | Implemented Core foundation: plugin definitions/provider declarations, registry (a short tool ID defined by several enabled plugins belongs to none of them), reply validation, directory pins, snapshot resolution, version matching and discovery plans; macOS uses it for its read-only settings pages and for Build and run tools, Windows is not connected | | Fixture folder | Contents | | --- | --- | @@ -64,8 +64,10 @@ and `toolchainProviders: 1` declarations and provides the `toolchains.*` command listed there. `executableResolution: 2` applies to tool-definition candidates only; it does not admit new sources into existing Build candidate sets. Definitions are entirely -plugin-owned, with reserved short IDs checked against host-verified official -provenance when constructing the registry. +plugin-owned. Any plugin may define a short ID such as `compiler` or a qualified +`:`; the host supplies no provenance evidence. A short ID defined +by more than one enabled plugin belongs to none of them: the registry omits +those definitions and reports the contest as a conflict. macOS advertises these capabilities for its read-only toolchain settings pages. The native adapter scans declarations, probes checked executables, queries plugin @@ -276,7 +278,7 @@ activation event, and a plugin may consist of grammars alone. | `runtimeDependencies` | Runtimes the host must discover and pass to the server. API 1 tools are `node` and `go`; API 2 adds `java`. The host exports each path as `LITHE__PATH` (`LITHE_NODE_PATH`, `LITHE_GO_PATH`; a user-set value takes precedence) and prepends its directory to the server's `PATH`, because servers such as gopls run the tool themselves and an app opened from the Dock does not inherit the shell `PATH`. A project-local copy is never chosen, since servers start automatically when a file opens. | | `initializationOptions` | API 2. A JSON object sent as the LSP `initializationOptions`. Every string value inside it is a launch template. | -| `hostIntegration` | API 2, official plugins only. A named integration implemented in the host, with the package resources it reads; see Host integrations. | +| `hostIntegration` | API 2. Any plugin may declare one. A named integration implemented in the host, with the package resources it reads; see Host integrations. | | `requestMethods`, `commands`, `serializedCommands`, `forwardNotifications`, `state`, `remote` | `languageServers` 3. What the plugin backend may ask of the running server and how the host keeps its state; see [Plugin-owned sessions](#plugin-owned-sessions-languageservers-3). | The host owns the language-server process lifecycle, the LSP session, and @@ -366,10 +368,16 @@ the files it needs are inside the plugin's own package: The plugin owns the files; the host owns the behavior. The host enables an integration only for the server that declares it and never matches on a language ID or looks for the files anywhere else, so removing the plugin -removes the integration's inputs with it. Because an integration runs trusted -host code, only plugins in the `dev.lithe` namespace may declare one; any -other package, or an ID the host does not implement, is refused with -`plugin.integration.unsupported`. +removes the integration's inputs with it. Any plugin may declare one, whatever +its namespace. The integration is host code, but it reads only resources inside +the declaring plugin's own package and hands them to the language server or +helper process that same plugin starts (extension bundles, agents, a test +runner, debugging); it grants the plugin no permission beyond running its own +server process, which an installed plugin may already do once the user confirms +the install review. An ID the host does not implement is refused with +`plugin.integration.unsupported`. A build system's `hostIntegration` is a +different declaration and stays limited to official plugins; see +[Host integrations](#host-integrations) under API level 3: build systems. Each resource is a package-relative path, or a list of them, that may vary only by `${arch}`. An integration defines which names it reads; a missing @@ -2423,7 +2431,7 @@ tool settings, exactly as the tool's own settings page does. | Property | Meaning | | --- | --- | -| `tool` | Required. A tool definition ID: the short ID of a verified official definition, otherwise `:`. There is no closed list; a tool nobody declares has no installations. | +| `tool` | Required. A tool definition ID: a short ID such as `compiler` defined by exactly one enabled plugin, or `:`. There is no closed list; a tool nobody declares has no installations, and neither has a short ID several enabled plugins define, because it belongs to none of them. | | `minimumVersion` | Optional, one to three dotted numbers. An installation below it is not selectable. | | `pinAs` | Optional. `project` saves the selection as the created project's own tool choice (the same per-directory pin the tool's settings page edits), so the project builds and runs with the installation picked here. Without it the selection only feeds the steps. | | `optional` | Optional. Without a usable installation an optional field stays empty and leaves no pin; otherwise the form reports `required`. An installation below `minimumVersion` is not usable. | @@ -3769,7 +3777,7 @@ command payload is a Core `invalid_request` failure instead. | `plugin.test.invalidIdentifier` | Core | Empty or multi-line test identifier | | `plugin.test.unsupportedProject` | Core | No project marker in the workspace | | `plugin.execution.unavailable` | Core | The plugin has no run/test provider for the language | -| `plugin.integration.unsupported` | Core | A host integration the host does not implement, or one requested by a plugin outside the official namespace | +| `plugin.integration.unsupported` | Core | A host integration the host does not implement, or a build system's host integration requested by a plugin outside the official namespace | | `plugin.launch.unavailable` | Core | The plugin has no language server for the language, no provider process for the build system, or no such VCS provider | | `plugin.build.unavailable` | Core | The plugin declares no such build system, or the build system does not declare the requested operation | | `plugin.build.invalidSelection` | Core | A build request selects a scope, target, filter, or argument the declaration does not allow, or misses a required path | @@ -4226,7 +4234,7 @@ including when a prerequisite runtime fails first; they cannot silently fall back to Provider discovery. Host-managed `java`, `node` or `go` dependencies may select one such candidate, while retaining Core's version probe. Maven declares its namespace-owned tool -and the official Java definition for its Java prerequisite; no Maven-specific +and uses the short `java` definition for its Java prerequisite; no Maven-specific JDK registry entry is added. Its backend bootstrap remains a separate consumer. Passive import performs no version or runtime probes; a definition that requires verification is not reported as an already verified import executable. diff --git a/shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json b/shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json index a31b117d6e..d29eb3ce07 100644 --- a/shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json +++ b/shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json @@ -230,12 +230,6 @@ "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/id", "value": "roslyn" }], "expectedCode": "plugin.integration.unsupported" }, - { - "name": "host integration requested outside the official namespace", - "base": "language-host-integration.plugin.json", - "edits": [{ "op": "set", "path": "/id", "value": "io.example.java" }, { "op": "set", "path": "/vendor/signatureRequirement", "value": "none" }], - "expectedCode": "plugin.integration.unsupported" - }, { "name": "jdt integration without a java runtime launch", "base": "language-host-integration.plugin.json", diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index f31a0e04f8..c4967086a4 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -1021,7 +1021,7 @@ "optional": { "type": "boolean" }, "runtime": { "enum": ["java"] }, "tool": { - "description": "tool: a tool definition ID, the short ID of a verified official definition or :.", + "description": "tool: a tool definition ID, a short ID or :; a short ID that several enabled plugins define belongs to none of them.", "type": "string", "maxLength": 192, "pattern": "^([a-z0-9]+([.-][a-z0-9]+)*:)?[a-z][a-z0-9-]{0,63}$" @@ -1486,7 +1486,7 @@ "enum": ["supported", "unsupported"] }, "hostIntegration": { - "description": "pluginAPIVersion 2, official plugins only: a named host integration and the package resources it reads.", + "description": "pluginAPIVersion 2: a named host integration and the package resources it reads.", "type": "object", "additionalProperties": false, "required": ["id"], diff --git a/shared/contracts/plugins/v1/toolchains/README.md b/shared/contracts/plugins/v1/toolchains/README.md index e34c9595f4..80654a29ef 100644 --- a/shared/contracts/plugins/v1/toolchains/README.md +++ b/shared/contracts/plugins/v1/toolchains/README.md @@ -14,8 +14,6 @@ settings detections are never used as launch permits. Existing Java and language-server consumers still use their previous adapters. Windows has no toolchain adapter and does not advertise `toolDefinitions` or `toolchainProviders`, so Core refuses packages that require them there. -Publisher signatures provide independent origin evidence when present; legacy -unsigned records never do. Consumer migration, the native query lifecycle and cache, pin saving and version-manager plugins are not specified yet. The manifest shape is [plugin-manifest.schema.json](../plugin-manifest.schema.json). @@ -53,9 +51,12 @@ by ID instead; see [`instantiateLaunch`](#launching-a-tool-definition). `toolchains.provide` may influence which program a defined tool launches. It does not grant arbitrary command replacement, installation, network or workspace-code -execution. Short definition IDs require independently verified official origin; -community definitions use `:`. Community providers may -answer short IDs. No host-owned tool table, name alias or manager parser is added. +execution. Any plugin may define a short ID such as `compiler` or a qualified +`:`; the prefix of a qualified ID must be the defining +plugin's own ID, so qualified IDs of different plugins never collide. A short ID +defined by more than one enabled plugin belongs to none of them (see the registry +rules below). Providers may answer short IDs. No host-owned tool table, name +alias or manager parser is added. A native Build consumer that directly executes a tool currently accepts only `["${executable}", "${arguments}"]`: its tool context carries a program and @@ -105,7 +106,7 @@ are not a substitute for that adapter. These commands perform no native I/O. | Command | Input | Success key | | --- | --- | --- | -| `toolchains.registry` | `{plugins:[{manifest,verifiedOfficial}]}` | `registry` | +| `toolchains.registry` | `{plugins:[{manifest}]}` | `registry` | | `toolchains.decodeReply` | Query/response and native evidence described below | `snapshot` | | `toolchains.failureReply` | `{params,code}` described below | `reply` | | `toolchains.resolvePins` | `{document,directory,tool}` | `resolution` | @@ -115,13 +116,25 @@ are not a substitute for that adapter. These commands perform no native I/O. | `toolchains.discoveryPlan` | `{registry,platform}` | `plan` | | `toolchains.candidates` | Captured discovery input described below | `candidates` | -Every `registry` input is the raw `{plugins:[...]}` request, not a previously -normalized result. Core revalidates complete production manifests and the -host-supplied `verifiedOfficial` boolean; a plugin cannot assert that evidence -inside its own manifest. Output is `{definitions:[{pluginID,definition}], -providers:[{pluginID,provider}]}` in stable order. Duplicate plugin/tool ownership -rejects the batch without replacing earlier owners. Limits are 128 plugins, -512 aggregate definitions and 256 aggregate providers. +Every `registry` input is the raw `{plugins:[{manifest}]}` request of the enabled +plugins, not a previously normalized result. Core revalidates complete production +manifests; the host supplies no proof of a plugin's origin, and a plugin's own +claim of one is not read. Output is `{definitions:[{pluginID,definition}], +providers:[{pluginID,provider}],conflicts:[{tool,pluginIDs}]}` in stable order. + +A short ID defined by more than one enabled plugin belongs to none of them. Core +registers none of those definitions and registers every other definition as +usual. `conflicts` lists each contested tool once, sorted by `tool`, with the IDs +of all plugins that defined it, sorted. Ownership is never decided by arrival +order or by plugin ID order, because a hostile plugin could take a tool by being +loaded first or by choosing a smaller ID. A contested tool is absent from the +registry: `discoveryPlan` lists no scans or providers for it, and a consumer that +resolves it treats it as declared by nobody. A host should tell the user which +plugins contest the tool; the macOS settings page shows one diagnostic per +conflict and advises keeping only one of those plugins enabled. Qualified IDs +cannot be contested, since their prefix is the defining plugin's ID. A repeated +plugin ID still rejects the whole batch without replacing earlier owners. Limits +are 128 plugins, 512 aggregate definitions and 256 aggregate providers. ## Provider backend negotiation diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 5897ff7f08..c58f324e5a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1371,7 +1371,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `plugins.declarative.resolveSettings` | Merge a plugin's stored global and project setting layers into effective values with their source | | `plugins.declarative.workspaceConfiguration` | Expand a language server's `workspaceConfiguration` with the merged setting values | | `plugins.declarative.checkRuntimeVersion` | Decide whether a launch runtime's version output meets the declared minimum version | -| `toolchains.registry` | Revalidate complete enabled plugin manifests and host-verified official origin into a deterministic tool-definition/provider registry; no native host capability claim | +| `toolchains.registry` | Revalidate complete enabled plugin manifests, `{plugins:[{manifest}]}`, into a deterministic tool-definition/provider registry; the host supplies no origin evidence. A short tool ID defined by several enabled plugins belongs to none of them: none of those definitions is registered, the rest register as usual, and `conflicts: [{tool, pluginIDs}]` (sorted by tool, `pluginIDs` sorted) reports each contested tool. A repeated plugin ID rejects the batch. No native host capability claim | | `toolchains.decodeReply` | Validate an inert toolchain query/reply pair against bound provider identity, generation, declared tools and host-native filesystem evidence; no transport or native I/O | | `toolchains.failureReply` | Build the JSON-RPC error reply for a query that missed its deadline or found no backend, so the host passes it to `toolchains.decodeReply` like a provider's own error; cancellation has none | | `toolchains.resolvePins` | Validate a captured local document and find the nearest ancestor directory pin, retaining dormant and legacy entries without probing or writing | diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index a10ab55eeb..50b5399c6b 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9866,6 +9866,7 @@ "native.toolchainSettings.notAValidInstallation": "Not a valid installation", "native.toolchainSettings.notUsable": "Not usable", "native.toolchainSettings.installationsCouldNotBeChecked": "Installations of {arg1} could not be checked.", + "native.toolchainSettings.toolDefinedBySeveralPlugins": "Tool {arg1} is defined by more than one enabled plugin ({arg2}), so none of these definitions is used. Keep only one of these plugins enabled.", "native.toolchainSettings.discoveryDeclarationsUnchecked": "Installed toolchain declarations could not be checked for discovery.", "native.toolchainSettings.onlyFirstVersionsChecked": "Only the first {arg1} installation versions were checked.", "native.toolchainSettings.versionsNotCheckedInTime": "Some installation versions were not checked in time.", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 3f5338ec21..e89cd3d8b7 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9866,6 +9866,7 @@ "native.toolchainSettings.notAValidInstallation": "不是有效的安装", "native.toolchainSettings.notUsable": "不可用", "native.toolchainSettings.installationsCouldNotBeChecked": "无法检查 {arg1} 的安装。", + "native.toolchainSettings.toolDefinedBySeveralPlugins": "多个已启用的插件都定义了工具 {arg1}({arg2}),这些定义都不会使用。请只保留其中一个插件启用。", "native.toolchainSettings.discoveryDeclarationsUnchecked": "无法检查已安装的工具链声明,未进行自动检测。", "native.toolchainSettings.onlyFirstVersionsChecked": "只检查了前 {arg1} 个安装的版本。", "native.toolchainSettings.versionsNotCheckedInTime": "部分安装的版本未能及时检查。", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 4f6cc6aaa8..ad719c7ed0 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -94799,6 +94799,24 @@ ] } }, + { + "id": "native.toolchainSettings.toolDefinedBySeveralPlugins", + "description": "Several enabled plugins define the same tool ID, so none of their definitions is used", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string", + "arg2": "string" + }, + "macos": { + "key": "Tool %@ is defined by more than one enabled plugin (%@), so none of these definitions is used. Keep only one of these plugins enabled.", + "arguments": [ + "arg1", + "arg2" + ] + } + }, { "id": "native.toolchainSettings.discoveryDeclarationsUnchecked", "description": "Installed tool declarations could not be checked, so automatic discovery did not run",