Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 不可用时拒绝授权并记录失败。会话身份、工作区上下文和资源归属仍由宿主复核。

### 宿主到后端的通知

Expand Down
Original file line number Diff line number Diff line change
@@ -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`
Original file line number Diff line number Diff line change
@@ -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`
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@
测试启动的独立产品进程不加载此测试模块,真实产品端到端测试仍需隔离系统环境。
可见模式的窗口和测试子进程仍由原测试的清理路径关闭。

原生 `NSApplication.runModal` 测试必须在有界的独立测试进程中执行,并由父进程
核验子进程真实完成目标用例。模态循环与 Swift Testing 共用主运行循环时,调用
`stopModal` 后进程可能在后续首个 WebKit 测试中提前返回成功,导致整套测试只跑到
一半;仅检查进程退出码无法发现这种情况。回归测试还要在父进程随后创建并加载
WebKit 预览,证明原生模态验证没有污染后续用例。

## 验证

- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh`。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = ["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<HostCatalog, RustCoreBridge.CoreCallError> = RustCoreBridge().executeResult(
command: "plugins.hostApi.describe", payload: EmptyPayload()
)

let contexts: PluginWorkspaceContexts
let subscriptions = PluginHostSubscriptions()
Expand Down Expand Up @@ -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<String> = ["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 }
}
}

Expand Down Expand Up @@ -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<Decision, RustCoreBridge.CoreCallError> = 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]
Expand Down
18 changes: 16 additions & 2 deletions macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
34 changes: 33 additions & 1 deletion macos/Tests/LitheTests/GoToLineDialogValidationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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("<title>ready</title><p>project page</p>")
#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()
Expand Down Expand Up @@ -96,3 +126,5 @@ struct GoToLineDialogValidationTests {
#expect(coordinator.confirmedInput == GoToLineInput(line: 1, column: 2, hasExplicitColumn: true))
}
}

private final class GoToLineModalBundleMarker: NSObject {}
Loading
Loading