diff --git a/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md b/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md index 74b282bf91..eac0a0a153 100644 --- a/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md +++ b/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md @@ -91,7 +91,8 @@ rootID 用按插件加密钥的 HMAC,而不是位置的普通哈希:普通 - `PluginHostServiceError.code` 用 `PluginHostErrorCode` 表示:未支持、未授权、上下文失效、authority 不支持、版本冲突、参数错误、超限、取消、超时、不存在(notFound)、只读(readOnly)、不支持的选项(unsupportedOption)、前置条件不满足(preconditionFailed)、忙(busy)、限流(rateLimited)、后端退出、其他失败。 - Core 只把白名单里的错误码透传给协议 3 的后端,每个码对应自己的 JSON-RPC 数值(`-32010` 起逐个加 1,一码一义),其余一律变成 `plugin.host.failed`。`-32000`~`-32009` 留给后端自己的失败,因为很多 JSON-RPC 库从 `-32000` 开始用。 - `PluginHostServiceError.details` 是可选的结构化对象(如 `currentVersion`、`retryAfterMilliseconds`),经 `lsp.hostResponse.error.details` 到 Core,序列化后不超过 4 KiB、且必须是对象才会以 `error.data.details` 送到协议 3 的后端,否则丢弃。 -- 宿主只授予自己真正注册了 handler 的服务:启动时把清单的 `hostServices` 与 `hostDispatcher.registeredServices` 取交集,再交给 Core。这样 `getCapabilities` 列出的方法都有人回答,不会出现“列出来但调用失败”。 +- 宿主只授予自己真正注册了 handler 的方法,以及由 Core 自己回答的能力;旧版聚合 grant 要求其中全部方法都已注册。这样 `getCapabilities` 列出的方法都有人回答,不会出现“列出来但调用失败”。 +- 宿主通过 Core 的 `plugins.hostApi.describe` 读取方法和 grant 的对应关系,再按已注册 handler 筛选;每次分发用 `plugins.hostApi.authorize` 核验方法、服务与当前会话的 grant。旧版聚合 grant 仍只覆盖其原有方法,新方法使用单方法 grant;Core 不可用时拒绝授权并记录失败。会话身份、工作区上下文和资源归属仍由宿主复核。 ### 宿主到后端的通知 diff --git a/.agents/notes/implemented/bug-fix/2026-11-05-plugin-host-method-service-authorization.md b/.agents/notes/implemented/bug-fix/2026-11-05-plugin-host-method-service-authorization.md new file mode 100644 index 0000000000..047918d929 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-05-plugin-host-method-service-authorization.md @@ -0,0 +1,35 @@ +# Agent 笔记:插件宿主请求的方法与服务必须一致 + +状态:已实现 + +## 先说结论 + +插件宿主收到请求时,授权必须同时核对方法、方法所属服务和当前会话的授权。不能只看方法是否在授权集合中,否则内部错误地传入不一致的服务名时,原生处理器仍可能被调用。 + +## 问题 + +旧的 macOS 分发器只要发现方法名在会话授权里就继续分发,没有核对请求携带的服务名。Core 通常会先生成正确的服务名,但宿主也负责复核;当内部调用或后续接线产生不一致的请求时,宿主原有检查不能拒绝它。 + +## 决策 + +Core 的宿主方法表是方法和服务关系的真源。macOS 在分发前调用 Core 检查该关系和当前 grant;不匹配返回不可用,Core 调用失败时拒绝并记录错误。会话、工作区和资源检查仍在宿主执行。授权目录及生命周期的完整边界见 `2026-10-30-plugin-backend-workspace-context.md`。 + +正确做法是用已授权的编辑器方法和其真实服务一起判断;不要把同一方法标成工作区服务再只凭方法授权放行。 + +## 考虑过的备选方案 + +在 Swift 增加第二张方法到服务的表能拦截不一致请求,但新增方法时需要维护两张表,容易再次漂移,因此复用 Core 的现有方法表。 + +## 后果 + +方法和服务不一致时会在调用原生处理器之前被拒绝。每次请求需要一次本地 Core 调用,宿主只传方法、服务和授权名称,不传文档内容或凭据。 + +## 验证 + +`PluginPublicHostAPITests.aMethodCannotBeDispatchedUnderADifferentService` 在旧分发器上失败、修复后通过。`shared/fixtures/plugins/host-api-v1.json` 覆盖旧版聚合授权、独立方法授权、未知方法和服务不匹配。 + +## 适用范围 + +- `macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift` +- `rust/lithe-core/src/plugins/host_api.rs` +- `macos/Tests/LitheTests/PluginPublicHostAPITests.swift` diff --git a/.agents/notes/implemented/bug-fix/2026-11-06-save-all-editor-drain.md b/.agents/notes/implemented/bug-fix/2026-11-06-save-all-editor-drain.md new file mode 100644 index 0000000000..eb03c89fd4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-06-save-all-editor-drain.md @@ -0,0 +1,36 @@ +# Agent 笔记:保存全部必须先读取编辑器最后一批输入 + +状态:已实现 + +## 先说结论 + +执行“保存全部”时,即使原生文档镜像当前显示为干净,只要编辑器仍需同步,就必须先等待编辑器交回最新内容。批次的选择规则属于 Rust Core;macOS 向 Core 提供真实同步状态,再由现有保存流程完成同步、取消检查和写入。 + +## 问题 + +原生镜像可能尚未收到浏览器编辑器的最后一批输入。旧的批次适配器把所有文档都标成已同步,Core 因而跳过看似干净的文档;用户保存或关闭时可能漏掉这些输入。 + +## 决策 + +批次请求中的 `contentSynchronized` 根据文档的 `needsEditorSynchronization` 生成。Core 继续决定哪些文档进入批次;真正的编辑器同步、磁盘写入和取消由现有宿主流程负责。取消或重置发生在同步期间时不得写出迟到内容。 + +正确做法是将仍需同步的文档交给 Core,即使它尚未标脏;不要把所有原生镜像都标记为已同步。 + +## 考虑过的备选方案 + +平台自行把所有打开文档加入保存批次可以避免漏读,但会复制 Core 的只读和成员选择规则,因此复用既有共享计划。 + +## 后果 + +干净但需要同步的编辑器会参加一次同步,真正没有改动时现有保存流程不会写磁盘。保存等待编辑器确认,失败或取消不能冒充成功。 + +## 验证 + +`cancellingSaveAllDuringRemoteDrainDoesNotWrite` 覆盖正常保存、取消和重置;`shared/fixtures/documents/save-batch-v1.json` 覆盖 Core 对未同步文档的选择。全量测试暴露了修复前不会进入同步回调的问题。 + +## 适用范围 + +- `macos/Sources/Lithe/Application/Features/DocumentSaveBatch.swift` +- `macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift` +- `rust/lithe-core/src/project/document_save_batch.rs` +- `macos/Tests/LitheTests/LitheCoreLogicTests.swift` diff --git a/.agents/notes/implemented/testing/2026-10-09-macos-test-desktop-isolation.md b/.agents/notes/implemented/testing/2026-10-09-macos-test-desktop-isolation.md index 5245de6ebc..4ed01ac9e8 100644 --- a/.agents/notes/implemented/testing/2026-10-09-macos-test-desktop-isolation.md +++ b/.agents/notes/implemented/testing/2026-10-09-macos-test-desktop-isolation.md @@ -59,6 +59,12 @@ 测试启动的独立产品进程不加载此测试模块,真实产品端到端测试仍需隔离系统环境。 可见模式的窗口和测试子进程仍由原测试的清理路径关闭。 +原生 `NSApplication.runModal` 测试必须在有界的独立测试进程中执行,并由父进程 +核验子进程真实完成目标用例。模态循环与 Swift Testing 共用主运行循环时,调用 +`stopModal` 后进程可能在后续首个 WebKit 测试中提前返回成功,导致整套测试只跑到 +一半;仅检查进程退出码无法发现这种情况。回归测试还要在父进程随后创建并加载 +WebKit 预览,证明原生模态验证没有污染后续用例。 + ## 验证 - `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh`。 diff --git a/macos/Sources/Lithe/Application/Features/DocumentSaveBatch.swift b/macos/Sources/Lithe/Application/Features/DocumentSaveBatch.swift index 0f16222d88..da350c5079 100644 --- a/macos/Sources/Lithe/Application/Features/DocumentSaveBatch.swift +++ b/macos/Sources/Lithe/Application/Features/DocumentSaveBatch.swift @@ -26,13 +26,14 @@ enum DocumentSaveBatchCore { let id: String let dirty: Bool let readOnly: Bool - let contentSynchronized = true + let contentSynchronized: Bool @MainActor init(_ document: EditorDocument) { id = document.id.uuidString dirty = document.isDirty readOnly = document.isReadOnly + contentSynchronized = !document.needsEditorSynchronization } } diff --git a/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift b/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift index f65312a4c0..87d8da06a8 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift @@ -240,10 +240,17 @@ final class PluginHostSubscriptions { /// granted; Core checked the same, and this is the host's own check. @MainActor final class PluginHostServiceDispatcher { - /// Services every plugin-owned session may call without a grant. - /// `commands` has no methods: its notification brings the plugin's own - /// commands to its backend. - static let implicitServices: Set = ["host", "subscriptions", "commands"] + private struct HostMethod: Decodable { + let method: String + let requiredGrant: String? + let coreServed: Bool + enum CodingKeys: String, CodingKey { case method, coreServed; case requiredGrant = "grant" } + } + private struct HostCatalog: Decodable { let methods: [HostMethod] } + private struct EmptyPayload: Encodable {} + private static let catalog: Result = RustCoreBridge().executeResult( + command: "plugins.hostApi.describe", payload: EmptyPayload() + ) let contexts: PluginWorkspaceContexts let subscriptions = PluginHostSubscriptions() @@ -297,22 +304,18 @@ final class PluginHostServiceDispatcher { Array(Set(uniqueHandlers.map(\.service))).sorted() } - /// Services Core answers itself. The host has no handler for them, but - /// the plugin is still granted them: a call between two plugins runs - /// inside Core, and the host only starts the callee's backend. - static let coreServedGrants: Set = ["functions"] - func supportedGrants(_ requested: [String]) -> [String] { - requested.filter { grant in - if Self.coreServedGrants.contains(grant) { return true } - if handlers[grant] != nil { return true } - let legacy: [String] - switch grant { - case "workspace": legacy = ["listContexts", "getContext", "getFolders"] - case "credentials": legacy = ["get", "set", "delete"] - default: return false - } - return legacy.allSatisfy { handlers["lithe/host/\(grant)/\($0)"] != nil } + let catalog: HostCatalog + switch Self.catalog { + case .success(let value): catalog = value + case .failure(let error): + logSink?.emit(AppCoreFallbackSelected(error: LogFailure(domain: "RustCore", code: error.code), + stage: "plugins.hostApi.describe")) + return [] + } + return requested.filter { grant in + let methods = catalog.methods.filter { $0.requiredGrant == grant } + return !methods.isEmpty && methods.allSatisfy { $0.coreServed || handlers[$0.method] != nil } } } @@ -397,12 +400,32 @@ final class PluginHostServiceDispatcher { .backendExited, L10n.string("The plugin backend is no longer running.") ).failure) } - let legacyMethod = request.method.hasPrefix("lithe/host/credentials/") - || ["listContexts", "getContext", "getFolders"].map { "lithe/host/workspace/" + $0 }.contains(request.method) - guard Self.implicitServices.contains(request.service) || granted.contains(request.method) - || (legacyMethod && granted.contains(request.service)) else { + struct Authorization: Encodable { + let method: String + let service: String + let grants: [String] + } + struct Decision: Decodable { let allowed: Bool; let code: String? } + let outcome: Result = RustCoreBridge().executeResult( + command: "plugins.hostApi.authorize", + payload: Authorization(method: request.method, service: request.service, grants: granted.sorted()) + ) + let decision: Decision + switch outcome { + case .success(let value): decision = value + case .failure(let error): + if let plugin = LogSegment(pluginID) { + logSink?.emit(PluginsHostRequestFailed(error: LogFailure(domain: "RustCore", code: error.code), + plugin: plugin, operationID: request.id)) + } + return .failure(PluginHostServiceError(.unavailable, + L10n.string("The plugin is not allowed to use the host service \(request.service).") + ).failure) + } + guard decision.allowed else { + let code = decision.code.flatMap(PluginHostErrorCode.init(rawValue:)) ?? .unavailable return .failure(PluginHostServiceError( - .permissionDenied, L10n.string("The plugin is not allowed to use the host service \(request.service).") + code, L10n.string("The plugin is not allowed to use the host service \(request.service).") ).failure) } let params: [String: ToolingJSONValue] diff --git a/macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift b/macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift index 6ff057add6..39dcd89806 100644 --- a/macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift +++ b/macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift @@ -37,18 +37,32 @@ struct AgentThoughtTranscriptTests { @MainActor func headers() -> [AnyObject] { accessibilityElements(in: host).filter { $0.accessibilityIdentifier?() == "agent-process-group-header" } } + @MainActor func state(of element: AnyObject) -> String? { + let value: String? = element.accessibilityValue?() + return value + } try await renderUntil(host, expected: "one process disclosure") { headers().count == 1 } let header = try #require(headers().first) + let collapsedValue = try #require(state(of: header)) let frame = header.accessibilityFrame?() ?? .zero #expect(frame.height < 32, "Collapsed process should occupy one compact line") let collapsed = try snapshot(host) try record(host, name: "mixed-collapsed") try pressThought(in: host, window: window) - try await renderUntil(host, expected: "expanded ordered process") { try snapshot(host) != collapsed } + try await renderUntil(host, expected: "expanded ordered process") { + try snapshot(host) != collapsed && headers().first.flatMap { state(of: $0) } != collapsedValue + } #expect(headers().count == 1, "Individual thoughts must not create nested thinking headers") try record(host, name: "mixed-expanded") try pressThought(in: host, window: window) - try await renderUntil(host, expected: "compact reply after collapsing") { try snapshot(host) == collapsed } + // The disclosure's state is stable even when unrelated scrolling or + // cursor animation changes pixels elsewhere in the full frame. + try await renderUntil(host, expected: "compact reply after collapsing") { + headers().first.flatMap { state(of: $0) } == collapsedValue + } + #expect(accessibilityElements(in: host).contains { + (state(of: $0) ?? "").contains("This is the final answer.") + }, "The final reply remains visible after the process collapses") } } diff --git a/macos/Tests/LitheTests/GoToLineDialogValidationTests.swift b/macos/Tests/LitheTests/GoToLineDialogValidationTests.swift index 919fb86452..286c0f4ef5 100644 --- a/macos/Tests/LitheTests/GoToLineDialogValidationTests.swift +++ b/macos/Tests/LitheTests/GoToLineDialogValidationTests.swift @@ -31,7 +31,11 @@ struct GoToLineDialogValidationTests { } @Test - func validationCompletesInsideTheNativeModalRunLoop() { + func validationCompletesInsideTheNativeModalRunLoop() async throws { + guard ProcessInfo.processInfo.environment["LITHE_GOTO_LINE_MODAL_CHILD"] == "1" else { + try await runModalValidationInChild() + return + } let app = NSApplication.shared let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 160, height: 80), styleMask: [.titled], backing: .buffered, defer: false) @@ -58,6 +62,32 @@ struct GoToLineDialogValidationTests { #expect(coordinator.confirmedInput == GoToLineInput(line: 11, column: 2, hasExplicitColumn: true)) } + @Test + func modalValidationDoesNotInterruptALaterWebPreview() async throws { + try await runModalValidationInChild() + let browser = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { browser.tearDown() } + browser.show("ready

project page

") + #expect(await browser.waitForTitle("ready")) + } + + private func runModalValidationInChild() async throws { + let image = try #require(Bundle(for: GoToLineModalBundleMarker.self).executableURL) + var environment = ProcessInfo.processInfo.environment + environment["LITHE_GOTO_LINE_MODAL_CHILD"] = "1" + let result = try await TestProcess.run( + executableURL: URL(fileURLWithPath: CommandLine.arguments[0]), + arguments: ["--test-bundle-path", image.path, "--testing-library", "swift-testing", "--filter", + "GoToLineDialogValidationTests.*validationCompletesInsideTheNativeModalRunLoop"], + currentDirectoryURL: FileManager.default.temporaryDirectory, + environment: environment, timeout: .seconds(10) + ) + let output = String(decoding: result.output, as: UTF8.self) + try #require(result.terminationStatus == 0, "Native modal validation failed: \(output)") + #expect(output.contains("Test run with 1 test in 1 suite passed"), + "Native modal validation must finish before the child exits: \(output)") + } + @Test func staleValidationCannotReplaceTheCurrentInput() async throws { let firstStarted = TestGate() @@ -96,3 +126,5 @@ struct GoToLineDialogValidationTests { #expect(coordinator.confirmedInput == GoToLineInput(line: 1, column: 2, hasExplicitColumn: true)) } } + +private final class GoToLineModalBundleMarker: NSObject {} diff --git a/macos/Tests/LitheTests/PluginPublicHostAPITests.swift b/macos/Tests/LitheTests/PluginPublicHostAPITests.swift index cdffb3c31e..45375a5c8a 100644 --- a/macos/Tests/LitheTests/PluginPublicHostAPITests.swift +++ b/macos/Tests/LitheTests/PluginPublicHostAPITests.swift @@ -57,6 +57,18 @@ struct PluginPublicHostAPITests { #expect(dispatcher.supportedGrants(["lithe/host/editor/applyEdits", "lithe/host/editor/missing"]) == ["lithe/host/editor/applyEdits"]) } + @Test + func aMethodCannotBeDispatchedUnderADifferentService() async { + let contexts = PluginWorkspaceContexts() + let dispatcher = PluginHostServiceDispatcher(contexts: contexts) + dispatcher.register(PluginDocumentHostService(contexts: contexts)) + let method = "lithe/host/editor/applyEdits" + dispatcher.beginSession(pluginID: "plugin", hostServices: [method]) + defer { dispatcher.endSession(pluginID: "plugin") } + let forged = PluginHostRequest(id: "test-request", method: method, service: "workspace", params: .object([:])) + #expect(code(await dispatcher.dispatch(forged, pluginID: "plugin")) == "plugin.host.unavailable") + } + /// Core answers `functions` (calls between plugins) itself, so the host /// has no handler for it; a plugin that declares calls is still granted it. @Test @@ -73,11 +85,11 @@ struct PluginPublicHostAPITests { let dispatcher = PluginHostServiceDispatcher(contexts: contexts) let handler = HeldHandler() dispatcher.register(handler) - dispatcher.beginSession(pluginID: "plugin", hostServices: ["lithe/host/test/wait"]) + dispatcher.beginSession(pluginID: "plugin", hostServices: ["lithe/host/window/showInformation"]) defer { handler.release.open(); dispatcher.endSession(pluginID: "plugin") } let answered = TestGate() var answers: [Result] = [] - dispatcher.handle(request("test/wait", ["context": reference(context)]), pluginID: "plugin") { + dispatcher.handle(request("window/showInformation", ["context": reference(context)]), pluginID: "plugin") { answers.append($0); answered.open() } try #require(await handler.started.waitUntilOpen()) @@ -93,19 +105,19 @@ struct PluginPublicHostAPITests { let dispatcher = PluginHostServiceDispatcher(contexts: PluginWorkspaceContexts()) let handler = HeldHandler() dispatcher.register(handler) - let old = dispatcher.beginSession(pluginID: "plugin", hostServices: ["lithe/host/test/wait"]) - let current = dispatcher.beginSession(pluginID: "plugin", hostServices: ["lithe/host/test/wait"]) + let old = dispatcher.beginSession(pluginID: "plugin", hostServices: ["lithe/host/window/showInformation"]) + let current = dispatcher.beginSession(pluginID: "plugin", hostServices: ["lithe/host/window/showInformation"]) defer { handler.release.open(); dispatcher.endSession(pluginID: "plugin") } let answered = TestGate() var count = 0 - dispatcher.handle(request("test/wait", [:]), pluginID: "plugin", sessionID: current) { _ in count += 1; answered.open() } + dispatcher.handle(request("window/showInformation", [:]), pluginID: "plugin", sessionID: current) { _ in count += 1; answered.open() } try #require(await handler.started.waitUntilOpen()) dispatcher.cancel(hostRequestID: "test-request", pluginID: "plugin", sessionID: old) #expect(!handler.finished.isOpen) handler.release.open() try #require(await answered.waitUntilOpen()) #expect(count == 1) - dispatcher.handle(request("test/wait", [:]), pluginID: "plugin", sessionID: old) { _ in count += 1 } + dispatcher.handle(request("window/showInformation", [:]), pluginID: "plugin", sessionID: old) { _ in count += 1 } #expect(count == 1) } @@ -148,8 +160,8 @@ struct PluginPublicHostAPITests { } private final class HeldHandler: PluginHostServiceHandling { - let service = "test" - let methods: Set = ["lithe/host/test/wait"] + let service = "window" + let methods: Set = ["lithe/host/window/showInformation"] let started = TestGate(), release = TestGate(), finished = TestGate() func answer(_ call: PluginHostCall) async -> Result { defer { finished.open() } diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 4cbe3a54d5..08617f309e 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1793,12 +1793,13 @@ struct RunConfigurationIntegrationTests { catalog: LanguageProviderCatalog(descriptors: [descriptor]), runtimes: [runtime] ) + defer { manager.stopAll() } let root = URL(fileURLWithPath: "/tmp/go-project", isDirectory: true) let source = root.appendingPathComponent("main.go") #expect(manager.activeLanguageServerIDs.isEmpty) - #expect(manager.features(for: source).contains(.completion)) - #expect(manager.supportsGenericEditing(for: source)) + #expect(!manager.features(for: source).contains(.completion)) + #expect(!manager.supportsGenericEditing(for: source)) try manager.synchronizeLanguageServer( for: source, text: "package main\nfunc main() {}\n", @@ -1811,6 +1812,10 @@ struct RunConfigurationIntegrationTests { }) #expect(core.startCalls.count == 1) #expect(core.syncCalls.last?.fileURL == source.standardizedFileURL) + core.enqueueReady(capabilities: ["completion", "formatting"]) + try #require(await Self.waitForMainActorCondition { manager.languageServerStates["go"] == .ready }) + #expect(manager.features(for: source).contains(.completion)) + #expect(manager.supportsGenericEditing(for: source)) var completionResult: Result<[LanguageServerCompletionItem], Error>? try manager.completions( @@ -1821,6 +1826,10 @@ struct RunConfigurationIntegrationTests { ) { result in completionResult = result } + core.enqueueRequestSuccess(operation: .completion, result: [ + "isIncomplete": false, "items": [["label": "func", "insertText": "func"]] + ]) + try #require(await Self.waitForMainActorCondition { completionResult != nil }) #expect(try completionResult?.get().contains { $0.label == "func" } == true) #expect(throws: LanguageToolingSessionError.self) { @@ -1831,10 +1840,12 @@ struct RunConfigurationIntegrationTests { rootURL: root ) { _ in } } - #expect(throws: LanguageToolingSessionError.self) { - try manager.format(fileURL: source, text: "package main\n", rootURL: root) { _ in } - } - #expect(manager.activeLanguageServerIDs.isEmpty) + var formatResult: Result<[LanguageServerTextEdit], Error>? + try manager.format(fileURL: source, text: "package main\n", rootURL: root) { formatResult = $0 } + core.enqueueRequestSuccess(operation: .formatting, result: ["edits": []]) + try #require(await Self.waitForMainActorCondition { formatResult != nil }) + #expect(try formatResult?.get().isEmpty == true) + #expect(manager.activeLanguageServerIDs.contains("go")) } @Test @@ -2711,7 +2722,9 @@ struct RunConfigurationIntegrationTests { @Test func repeatedStartupFailuresStopUntilExplicitRestart() async throws { - let harness = makeLanguageServerHarness() + let harness = makeLanguageServerHarness(policyCore: RustCoreBridge()) + var now: UInt64 = 0 + harness.manager.monotonicNowMs = { now } defer { harness.manager.stopAll() } for _ in 0..<3 { let failed = TestGate() @@ -2721,6 +2734,7 @@ struct RunConfigurationIntegrationTests { try harness.manager.synchronizeLanguageServer(for: harness.source, text: "struct App {}", rootURL: harness.root) harness.core.enqueueFailure(code: "serverExited", message: "startup failed") #expect(await failed.waitUntilOpen()) + now += 5_000 } #expect(harness.core.startCalls.count == 3) for _ in 0..<5 { @@ -4343,7 +4357,8 @@ struct RunConfigurationIntegrationTests { private func makeLanguageServerHarness( initializeTimeoutNanoseconds: UInt64 = 10_000_000_000, - requestTimeoutNanoseconds: UInt64 = 30_000_000_000 + requestTimeoutNanoseconds: UInt64 = 30_000_000_000, + policyCore: (any LanguageToolingPolicyCore)? = nil ) -> LanguageServerReliabilityHarness { let descriptor = LanguageProviderDescriptor( id: "swift", @@ -4368,7 +4383,7 @@ struct RunConfigurationIntegrationTests { let runtime = TestLanguageServerRuntime(descriptor: descriptor, session: session) let manager = LanguageToolingSessionManager( catalog: LanguageProviderCatalog(descriptors: [descriptor]), - runtimes: [runtime] + runtimes: [runtime], policyCore: policyCore ) return LanguageServerReliabilityHarness( root: root, diff --git a/rust/lithe-core/src/plugins/host_api.rs b/rust/lithe-core/src/plugins/host_api.rs new file mode 100644 index 0000000000..374d547b59 --- /dev/null +++ b/rust/lithe-core/src/plugins/host_api.rs @@ -0,0 +1,122 @@ +//! Exposes the existing backend host-method rules to native dispatchers. +//! +//! Session ownership and context lifetime remain with the native host. The +//! method table and grant decisions stay with the same Core code that already +//! validates backend requests, so a new method cannot widen an old grant. + +use crate::lsp::interface::plugin_function::{self, HOST_METHODS, HOST_SERVICE_FUNCTIONS}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +/// Describes host methods using the same catalog as backend request routing. +pub(crate) fn describe_command(payload: Value) -> Result { + if payload.as_object().is_none_or(|object| !object.is_empty()) { + return Err(invalid()); + } + // Include every method compiled into this Core, irrespective of which + // protocol version a particular backend later negotiates. + let implicit = plugin_function::capabilities_answer(u64::MAX, &BTreeSet::new()); + let methods: Vec = HOST_METHODS + .iter() + .map(|entry| { + let is_implicit = implicit["services"][entry.service]["methods"] + .as_array() + .is_some_and(|methods| methods.iter().any(|method| method == entry.method)); + let grant = if is_implicit { + None + } else if entry.permission.is_some() { + Some(entry.method) + } else { + Some(entry.service) + }; + json!({"method":entry.method,"service":entry.service,"grant":grant, + "coreServed":entry.service == HOST_SERVICE_FUNCTIONS}) + }) + .collect(); + Ok(json!({"methods":methods})) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Authorization { + method: String, + service: String, + grants: Vec, +} + +/// Reuses Core's capability decisions with the host's current session grants. +pub(crate) fn authorize_command(payload: Value) -> Result { + let input: Authorization = serde_json::from_value(payload).map_err(|_| invalid())?; + if input.method.len() > 256 + || input.service.len() > 128 + || input.grants.len() > 512 + || input.grants.iter().any(|grant| grant.len() > 256) + { + return Err(invalid()); + } + let matching = HOST_METHODS + .iter() + .any(|entry| entry.method == input.method && entry.service == input.service); + if !matching { + return Ok(json!({"allowed":false,"code":"plugin.host.unavailable"})); + } + let grants = input.grants.into_iter().collect(); + let capabilities = plugin_function::capabilities_answer(u64::MAX, &grants); + let allowed = capabilities["services"][&input.service]["methods"] + .as_array() + .is_some_and(|methods| methods.iter().any(|method| method == &input.method)); + Ok(if allowed { + json!({"allowed":true}) + } else { + json!({"allowed":false,"code":"plugin.host.permissionDenied"}) + }) +} + +fn invalid() -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Invalid host API request.") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_preserves_legacy_and_individual_grants() { + let catalog = describe_command(json!({})).unwrap(); + let entries = catalog["methods"].as_array().unwrap(); + let method = |name: &str| { + entries + .iter() + .find(|entry| entry["method"] == name) + .unwrap() + }; + assert_eq!( + method("lithe/host/workspace/listContexts")["grant"], + "workspace" + ); + assert_eq!( + method("lithe/host/workspace/readFile")["grant"], + "lithe/host/workspace/readFile" + ); + assert!(method("lithe/host/subscriptions/dispose")["grant"].is_null()); + assert_eq!(method("lithe/host/functions/invoke")["coreServed"], true); + } + + #[test] + fn authorization_does_not_widen_metadata_grants_or_accept_spoofed_services() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/plugins/host-api-v1.json" + )) + .unwrap(); + for case in fixture["authorization"].as_array().unwrap() { + assert_eq!( + authorize_command(case["input"].clone()).unwrap(), + case["expected"], + "{}", + case["name"] + ); + } + } +} diff --git a/rust/lithe-core/src/plugins/mod.rs b/rust/lithe-core/src/plugins/mod.rs index bbf2d1eb35..31a792d313 100644 --- a/rust/lithe-core/src/plugins/mod.rs +++ b/rust/lithe-core/src/plugins/mod.rs @@ -10,6 +10,8 @@ pub mod editor_actions; pub mod editor_providers; pub(crate) mod executables; pub(crate) mod grammar; +/// Public host-method catalog and authorization for platform dispatchers. +pub(crate) mod host_api; pub(crate) mod keymap_import; /// Session fields of plugin-owned language servers and the launch their /// backend resolves. diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index e4b7810722..1ea65d11f3 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -123,6 +123,10 @@ pub enum CoreCommand { PluginsDeclarativeValidate, /// Validates native plugin UI data with declaration identity and node budgets. PluginsNativeUIValidateData, + /// Describes the Core-owned backend host-method catalog. + PluginsHostApiDescribe, + /// Rechecks a backend host request against Core's grant rules. + PluginsHostApiAuthorize, /// Expands a declarative plugin's run template (`plugins.declarative.runPlan`). PluginsDeclarativeRunPlan, /// Lists a declarative plugin's workspace and file tests (`plugins.declarative.discoverTests`). @@ -916,6 +920,8 @@ impl CoreCommand { } "plugins.declarative.decodeGrammar" => Some(Self::PluginsDeclarativeDecodeGrammar), "plugins.editorActions.decode" => Some(Self::PluginsEditorActionsDecode), + "plugins.hostApi.describe" => Some(Self::PluginsHostApiDescribe), + "plugins.hostApi.authorize" => Some(Self::PluginsHostApiAuthorize), "plugins.editorProviders.decode" => Some(Self::PluginsEditorProvidersDecode), "keymap.decodeImport" => Some(Self::KeymapDecodeImport), "keymap.planImportSources" => Some(Self::KeymapPlanImportSources), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 6f6e579ea5..f698e0c08d 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -390,6 +390,16 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { parsed.payload, crate::plugins::editor_actions::decode_command, ), + CoreCommand::PluginsHostApiDescribe => plugin_response( + id, + parsed.payload, + crate::plugins::host_api::describe_command, + ), + CoreCommand::PluginsHostApiAuthorize => plugin_response( + id, + parsed.payload, + crate::plugins::host_api::authorize_command, + ), CoreCommand::PluginsEditorProvidersDecode => plugin_response( id, parsed.payload, diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 8256aac7db..937882d858 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1594,6 +1594,8 @@ package manager owns the download and Lithe does not infer bytes from logs. | `toolchains.candidates` | List one tool's login-shell homes and host-scanned installations with checked executables, versions or skip reasons, and the automatic-layer choice; never starts a probe | | `plugins.declarative.decodeGrammar` | Validate independent static editor resources (`languages` 2) | | `plugins.editorActions.decode` | Validate independent action registration, collection or click results | +| `plugins.hostApi.describe` | Describe backend host methods and their required grants from Core's method table | +| `plugins.hostApi.authorize` | Check a method, service and current session grants using Core's capability rules; the host still checks session and resource ownership | | `plugins.editorProviders.decode` | Validate editor provider registrations and provider function results | | `run.decodeStart` | Validate a plugin backend's `lithe/host/run/start` params against its manifest and the workspace, and return the normalized start with temporary-file paths substituted | | `debug.decodeBackendConfiguration` | Validate a plugin backend's `lithe/host/debug/start` params against its manifest and return the qualified adapter, request, label and configuration | @@ -5463,6 +5465,20 @@ refuses is `plugin.grammar.invalid` with the failing `field`; a language without static editor resources is `plugin.grammar.unavailable`. Configuration-only languages return `grammar:null` and require no file read. Core reads no files. +`plugins.hostApi.describe` accepts `{}` and returns `{methods:[{method,service,grant,coreServed}]}`. +`grant` is null for implicit methods, a service name for legacy aggregate grants, or the +exact method for individual grants. `coreServed` identifies methods dispatched inside +Core rather than by a native handler. The host admits a requested grant only when all of +its non-Core methods have registered handlers. + +`plugins.hostApi.authorize` accepts `{method,service,grants}` and returns `{allowed:true}` +or `{allowed:false,code}`. It uses Core's method catalog and capability rules; unknown or +mismatched methods return `plugin.host.unavailable`, and missing grants return +`plugin.host.permissionDenied`. This decision does not replace negotiated-version, +parameter, current-session, workspace-context, or resource-ownership checks. Hosts must +deny access if the Core call fails. +Shared cases are in `shared/fixtures/plugins/host-api-v1.json`. + `plugins.editorActions.decode` accepts `{kind:"registration",value}` or `{kind:"actions",value,text}` and returns `{registration}` or `{actions}`. Core validates literal selectors, one-based UTF-16 ranges and bounded opaque diff --git a/shared/fixtures/plugins/host-api-v1.json b/shared/fixtures/plugins/host-api-v1.json new file mode 100644 index 0000000000..0f40eaaf29 --- /dev/null +++ b/shared/fixtures/plugins/host-api-v1.json @@ -0,0 +1,34 @@ +{ + "authorization": [ + { + "name": "metadata grant allows context listing", + "input": { "method": "lithe/host/workspace/listContexts", "service": "workspace", "grants": ["workspace"] }, + "expected": { "allowed": true } + }, + { + "name": "metadata grant does not grant file reads", + "input": { "method": "lithe/host/workspace/readFile", "service": "workspace", "grants": ["workspace"] }, + "expected": { "allowed": false, "code": "plugin.host.permissionDenied" } + }, + { + "name": "a method cannot be attributed to another service", + "input": { "method": "lithe/host/editor/applyEdits", "service": "workspace", "grants": ["workspace"] }, + "expected": { "allowed": false, "code": "plugin.host.unavailable" } + }, + { + "name": "unknown implicit method is unavailable", + "input": { "method": "lithe/host/unknown", "service": "host", "grants": [] }, + "expected": { "allowed": false, "code": "plugin.host.unavailable" } + }, + { + "name": "individual grant admits its own method", + "input": { "method": "lithe/host/window/showInformation", "service": "window", "grants": ["lithe/host/window/showInformation"] }, + "expected": { "allowed": true } + }, + { + "name": "implicit subscriptions require no grant", + "input": { "method": "lithe/host/subscriptions/dispose", "service": "subscriptions", "grants": [] }, + "expected": { "allowed": true } + } + ] +} diff --git a/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json b/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json index 8df1bc5ca5..80801f2b22 100644 --- a/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json +++ b/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json @@ -8,6 +8,8 @@ "verificationStatus": "pending", "evidence": [ "rust/lithe-core/src/lsp/interface/plugin_function.rs", + "rust/lithe-core/src/plugins/host_api.rs", + "shared/fixtures/plugins/host-api-v1.json", "rust/lithe-core/src/lsp/interface/engine.rs", "rust/lithe-core/src/plugins/backend.rs", "shared/contracts/plugins/v1/README.md", @@ -31,5 +33,5 @@ }, "owner": "Extensions", "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core,核对 function-protocol.json 的 hostRequests/hostFailures/hostNotifications、v2 清单拒绝 onStartupFinished 与 *、旧宿主拒绝 backend 2、v3 会话第 17 个挂起请求返回 plugin.host.limitExceeded(-32014)、$/cancelRequest 立即返回 -32800 并发布 hostRequestCancelled、details 只以不超过 4 KiB 的对象送到 v3 后端、v2 会话的 lsp.hostNotify 返回 delivered:false。macOS 宿主:scripts/macos/test/test-macos.sh --filter \"PluginHostServiceDispatchTests|MacPluginBackendSessionsTests\"。macOS 原生验收:debug 应用安装一个 backend 2、声明 onWorkspaceOpen 与 workspace.metadata 的示例插件,打开本地工作区后确认后端自动启动、listContexts/getFolders 返回该窗口的根;同一窗口换工作区后收到 didChangeContexts,旧 contextID 得到 plugin.host.contextExpired;执行插件命令后后端收到 commands/didRun;打开远程工作区时 folders 不含 path;关闭窗口、停用插件后用 pgrep 确认后端不残留。", - "notes": "本条覆盖 #1548 计划的 API-01、API-05、API-40、API-41(M0)以及 PR #1595 评审补充(宿主通知、错误码细分与 details、换工作区发新 contextID、按插件的 rootID、listContexts 不建目录、能力表只列有 handler 的服务)。macOS 已有 Core 共享测试与宿主单测,尚未做原生验收,所以验证状态为待验证。新安装带后端的包仍需重启才能激活;启动预算未定。工作区信任暂不引入。Windows/Tauri 尚无插件后端宿主。协议 3 内只增不改:新的宿主服务、选项和错误码靠 getCapabilities 发现,不再升协议版本;v3 请求里不认识的字段回 plugin.host.unsupportedOption(-32018)并在 details.option 写明。 本轮增加 ui:4 纯后端命令、初始化中排队与崩溃后再激活、后台两路启动、逐方法授权、128 项通知队列、发送 watchdog、会话 token 隔离和 context 关闭时取消。" + "notes": "本条覆盖 #1548 计划的 API-01、API-05、API-40、API-41(M0)以及 PR #1595 评审补充(宿主通知、错误码细分与 details、换工作区发新 contextID、按插件的 rootID、listContexts 不建目录、能力表只列有 handler 的服务)。macOS 已有 Core 共享测试与宿主单测,尚未做原生验收,所以验证状态为待验证。新安装带后端的包仍需重启才能激活;启动预算未定。工作区信任暂不引入。Windows/Tauri 尚无插件后端宿主。协议 3 内只增不改:新的宿主服务、选项和错误码靠 getCapabilities 发现,不再升协议版本;v3 请求里不认识的字段回 plugin.host.unsupportedOption(-32018)并在 details.option 写明。 本轮增加 ui:4 纯后端命令、初始化中排队与崩溃后再激活、后台两路启动、逐方法授权、128 项通知队列、发送 watchdog、会话 token 隔离和 context 关闭时取消。宿主授权目录和方法判定改由共享 Core 提供;macOS 仍核验会话、上下文和资源归属。" } diff --git a/shared/platform-feature-matrix/features/run-save-toolchain.json b/shared/platform-feature-matrix/features/run-save-toolchain.json index 785135fde4..0479986d5c 100644 --- a/shared/platform-feature-matrix/features/run-save-toolchain.json +++ b/shared/platform-feature-matrix/features/run-save-toolchain.json @@ -6,6 +6,8 @@ "macos": { "evidence": [ "macos/Sources/Lithe/Views/Run", + "macos/Sources/Lithe/Application/Features/DocumentSaveBatch.swift", + "shared/fixtures/documents/save-batch-v1.json", "macos/Sources/Lithe/Services/Java", "macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift", "rust/lithe-core/src/execution/configuration.rs",