diff --git a/packages/DBRepository/Sources/DBRepository/DBRepositoryAgentProfiles.swift b/packages/DBRepository/Sources/DBRepository/DBRepositoryAgentProfiles.swift index 0490fe4e..44b0f071 100644 --- a/packages/DBRepository/Sources/DBRepository/DBRepositoryAgentProfiles.swift +++ b/packages/DBRepository/Sources/DBRepository/DBRepositoryAgentProfiles.swift @@ -138,3 +138,5 @@ public extension DBRepository { ) } } + +extension DBRepository: AgentProfileCatalog {} diff --git a/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift b/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift index 39975646..945db70f 100644 --- a/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift +++ b/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift @@ -131,7 +131,7 @@ public extension DBRepository { } } - func listLatestPluginFactoryManifests() throws -> [(pluginID: String, version: String, manifestJSON: String, reviewSummary: String)] { + func listLatestPluginFactoryManifests() throws -> [PluginFactoryManifestRecord] { try withDatabaseHandle { handle in let sql = """ SELECT plugin_id, version, manifest_json, review_summary @@ -148,7 +148,7 @@ public extension DBRepository { throw Self.sqliteError(handle: handle, fallback: "Failed to prepare latest plugin factory manifests.") } defer { sqlite3_finalize(statement) } - var rows: [(pluginID: String, version: String, manifestJSON: String, reviewSummary: String)] = [] + var rows: [PluginFactoryManifestRecord] = [] while sqlite3_step(statement) == SQLITE_ROW { guard let id = sqlite3_column_text(statement, 0), @@ -159,7 +159,7 @@ public extension DBRepository { continue } rows.append( - ( + PluginFactoryManifestRecord( pluginID: String(cString: id), version: String(cString: version), manifestJSON: String(cString: manifest), @@ -195,3 +195,5 @@ public extension DBRepository { try savePluginFactoryRelease(release) } } + +extension DBRepository: PluginFactoryManifestCatalog {} diff --git a/packages/MCPServer/Sources/FactoryHarnessSupport/HarnessSecretAttacher.swift b/packages/MCPServer/Sources/FactoryHarnessSupport/HarnessSecretAttacher.swift index 4b6d2660..7402cda2 100644 --- a/packages/MCPServer/Sources/FactoryHarnessSupport/HarnessSecretAttacher.swift +++ b/packages/MCPServer/Sources/FactoryHarnessSupport/HarnessSecretAttacher.swift @@ -1,5 +1,4 @@ import Foundation -import MCPServer import Structure public struct HarnessSecretAttacher: HostHTTPSecretAttacher { diff --git a/packages/MCPServer/Sources/MCPServer/Script/HostHTTPClient.swift b/packages/MCPServer/Sources/MCPServer/Script/HostHTTPClient.swift index 1c2546e5..1e395c19 100644 --- a/packages/MCPServer/Sources/MCPServer/Script/HostHTTPClient.swift +++ b/packages/MCPServer/Sources/MCPServer/Script/HostHTTPClient.swift @@ -21,10 +21,6 @@ public struct HostHTTPFetch: Sendable { } } -public protocol HostHTTPSecretAttacher: Sendable { - func apply(url: URL) async -> (url: URL, headers: [String: String]) -} - public actor HostHTTPClient { public static let shared = HostHTTPClient() diff --git a/packages/Structure/Sources/AgentRuntime/AgentProfileCatalog.swift b/packages/Structure/Sources/AgentRuntime/AgentProfileCatalog.swift new file mode 100644 index 00000000..1bf24e8d --- /dev/null +++ b/packages/Structure/Sources/AgentRuntime/AgentProfileCatalog.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Persistence for agent profiles. UI and AppLayer take this protocol, not SQLite. +public protocol AgentProfileCatalog: Actor { + func upsertAgentProfile(_ profile: AgentProfile) throws + func listAgentProfiles() throws -> [AgentProfile] + func agentProfile(id: String) throws -> AgentProfile? + func agentProfile(handle: String) throws -> AgentProfile? + func deleteAgentProfile(id: String) throws +} diff --git a/packages/Structure/Sources/AppLayerServices/AppLayerFeatureCatalog.swift b/packages/Structure/Sources/AppLayerServices/AppLayerFeatureCatalog.swift new file mode 100644 index 00000000..bccbeecd --- /dev/null +++ b/packages/Structure/Sources/AppLayerServices/AppLayerFeatureCatalog.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Major product surfaces that must be driven from Structure contracts. +/// Persistence may still be SQLite; UI must not invent parallel DTOs or skip these types. +public enum AppLayerFeature: String, CaseIterable, Sendable { + case agentProfiles + case pluginCredentials + case pluginFactoryManifests + case hostHTTPSecrets + case scriptReview + case mcpToolCatalog + case conversationMCPBridge +} + +public struct AppLayerFeatureContract: Sendable, Hashable { + public let feature: AppLayerFeature + /// Type names that live in Structure and that this feature must use. + public let structureTypeNames: [String] + /// SQLite is an allowed implementation of Structure catalogs. + public let persistenceMayBeSQLite: Bool + /// When true, UI files for this feature must not import MCPServer. + public let uiMustNotImportMCPServer: Bool + + public init( + feature: AppLayerFeature, + structureTypeNames: [String], + persistenceMayBeSQLite: Bool, + uiMustNotImportMCPServer: Bool + ) { + self.feature = feature + self.structureTypeNames = structureTypeNames + self.persistenceMayBeSQLite = persistenceMayBeSQLite + self.uiMustNotImportMCPServer = uiMustNotImportMCPServer + } +} + +public enum AppLayerFeatureCatalog { + public static let contracts: [AppLayerFeatureContract] = [ + AppLayerFeatureContract( + feature: .agentProfiles, + structureTypeNames: ["AgentProfile", "AgentProfileCatalog"], + persistenceMayBeSQLite: true, + uiMustNotImportMCPServer: true + ), + AppLayerFeatureContract( + feature: .pluginCredentials, + structureTypeNames: ["PluginSecretDescriptor", "PluginCredentialGroup"], + persistenceMayBeSQLite: true, + uiMustNotImportMCPServer: true + ), + AppLayerFeatureContract( + feature: .pluginFactoryManifests, + structureTypeNames: ["PluginFactoryManifestRecord", "PluginFactoryManifestCatalog"], + persistenceMayBeSQLite: true, + uiMustNotImportMCPServer: true + ), + AppLayerFeatureContract( + feature: .hostHTTPSecrets, + structureTypeNames: ["HostHTTPAccessGate", "HostHTTPSecretAttacher"], + persistenceMayBeSQLite: false, + uiMustNotImportMCPServer: true + ), + AppLayerFeatureContract( + feature: .scriptReview, + structureTypeNames: ["ScriptReviewer", "ScriptExecutionArguments"], + persistenceMayBeSQLite: false, + uiMustNotImportMCPServer: false + ), + AppLayerFeatureContract( + feature: .mcpToolCatalog, + structureTypeNames: ["AllowedMCPTool"], + persistenceMayBeSQLite: false, + uiMustNotImportMCPServer: true + ), + AppLayerFeatureContract( + feature: .conversationMCPBridge, + structureTypeNames: ["AllowedMCPTool"], + persistenceMayBeSQLite: false, + uiMustNotImportMCPServer: false + ), + ] + + public static func contract(for feature: AppLayerFeature) -> AppLayerFeatureContract { + contracts.first { $0.feature == feature }! + } +} diff --git a/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretDescriptor.swift b/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretDescriptor.swift index 01b4bc7f..33fce91b 100644 --- a/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretDescriptor.swift +++ b/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretDescriptor.swift @@ -22,6 +22,31 @@ public struct PluginSecretDescriptor: Codable, Sendable, Hashable { } } +/// Settings list row for a plugin that declared secrets. No secret values. +public struct PluginCredentialGroup: Equatable, Identifiable, Sendable, Hashable { + public let pluginID: String + public let isConnector: Bool + public let secrets: [PluginSecretDescriptor] + + public var id: String { pluginID } + + public var displayName: String { + pluginID + .split(separator: "-") + .map { part in + let lower = part.lowercased() + return lower.prefix(1).uppercased() + lower.dropFirst() + } + .joined(separator: " ") + } + + public init(pluginID: String, isConnector: Bool, secrets: [PluginSecretDescriptor]) { + self.pluginID = pluginID + self.isConnector = isConnector + self.secrets = secrets + } +} + /// Reverse-XPC / approval tool name for collecting plugin Keychain secrets. public enum PluginCredentialPrompt { public static let toolName = "plugin.credentials" diff --git a/packages/Structure/Sources/Plugin/Factory/PluginFactoryManifestRecord.swift b/packages/Structure/Sources/Plugin/Factory/PluginFactoryManifestRecord.swift new file mode 100644 index 00000000..b0a5b21e --- /dev/null +++ b/packages/Structure/Sources/Plugin/Factory/PluginFactoryManifestRecord.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Latest factory manifest row for a compiled plugin. Values are JSON text, not binaries. +public struct PluginFactoryManifestRecord: Sendable, Hashable { + public let pluginID: String + public let version: String + public let manifestJSON: String + public let reviewSummary: String + + public init(pluginID: String, version: String, manifestJSON: String, reviewSummary: String) { + self.pluginID = pluginID + self.version = version + self.manifestJSON = manifestJSON + self.reviewSummary = reviewSummary + } +} + +/// Read path for factory manifests. UI and AppLayer take this protocol, not SQLite. +public protocol PluginFactoryManifestCatalog: Actor { + func listLatestPluginFactoryManifests() throws -> [PluginFactoryManifestRecord] +} diff --git a/packages/Structure/Sources/Plugin/HTTP/HostHTTPAccess.swift b/packages/Structure/Sources/Plugin/HTTP/HostHTTPAccess.swift index b9848afc..a197446e 100644 --- a/packages/Structure/Sources/Plugin/HTTP/HostHTTPAccess.swift +++ b/packages/Structure/Sources/Plugin/HTTP/HostHTTPAccess.swift @@ -28,6 +28,11 @@ public protocol HostHTTPAccessGate: Sendable { func authorize(url: URL, invokeID: String) async -> HostHTTPAccessDecision } +/// Injected into `HostHTTPClient`. Attaches Keychain secrets without exposing values to the guest. +public protocol HostHTTPSecretAttacher: Sendable { + func apply(url: URL) async -> (url: URL, headers: [String: String]) +} + public enum HostHTTPAccessDecision: Sendable, Equatable { case allow case deny(String) diff --git a/packages/Structure/Tests/StructureTests/AppLayerFeatureCatalogTests.swift b/packages/Structure/Tests/StructureTests/AppLayerFeatureCatalogTests.swift new file mode 100644 index 00000000..27423dbd --- /dev/null +++ b/packages/Structure/Tests/StructureTests/AppLayerFeatureCatalogTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Structure +import Testing + +@Suite struct AppLayerFeatureCatalogTests { + @Test func everyFeatureListsStructureContracts() { + #expect(AppLayerFeatureCatalog.contracts.count == AppLayerFeature.allCases.count) + for feature in AppLayerFeature.allCases { + let contract = AppLayerFeatureCatalog.contract(for: feature) + #expect(!contract.structureTypeNames.isEmpty) + } + } + + @Test func agentProfilesAndManifestsUseCatalogProtocols() { + let profiles = AppLayerFeatureCatalog.contract(for: .agentProfiles) + #expect(profiles.structureTypeNames.contains("AgentProfileCatalog")) + #expect(profiles.uiMustNotImportMCPServer) + let manifests = AppLayerFeatureCatalog.contract(for: .pluginFactoryManifests) + #expect(manifests.structureTypeNames.contains("PluginFactoryManifestCatalog")) + } + + @Test func hostHTTPSecretAttacherLivesInStructure() async { + struct PassThrough: HostHTTPSecretAttacher { + func apply(url: URL) async -> (url: URL, headers: [String: String]) { + (url, [:]) + } + } + let url = URL(string: "https://example.com")! + let attached = await PassThrough().apply(url: url) + #expect(attached.url == url) + #expect(attached.headers.isEmpty) + } + + @Test func inMemoryAgentProfileCatalogSatisfiesProtocol() async throws { + let catalog = MemoryAgentProfileCatalog() + let profile = AgentProfile.orchestratorDefault(modelJSON: Data(#"{"openai":"gpt-5.6-luna"}"#.utf8)) + try await catalog.upsertAgentProfile(profile) + #expect(try await catalog.listAgentProfiles().count == 1) + #expect(try await catalog.agentProfile(handle: "orchestrator")?.id == profile.id) + try await catalog.deleteAgentProfile(id: profile.id) + #expect(try await catalog.listAgentProfiles().isEmpty) + } + + @Test func pluginCredentialGroupDisplayNameIsHumanReadable() { + let group = PluginCredentialGroup( + pluginID: "slack-connection", + isConnector: true, + secrets: [PluginSecretDescriptor(id: "bot_token", label: "Bot Token", kind: "token")] + ) + #expect(group.displayName == "Slack Connection") + } +} + +actor MemoryAgentProfileCatalog: AgentProfileCatalog { + private var profiles: [String: AgentProfile] = [:] + + func upsertAgentProfile(_ profile: AgentProfile) throws { + profiles[profile.id] = profile + } + + func listAgentProfiles() throws -> [AgentProfile] { + profiles.values.sorted { lhs, rhs in + if lhs.sortOrder != rhs.sortOrder { + return lhs.sortOrder < rhs.sortOrder + } + return lhs.displayName < rhs.displayName + } + } + + func agentProfile(id: String) throws -> AgentProfile? { + profiles[id] + } + + func agentProfile(handle: String) throws -> AgentProfile? { + let normalized = handle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return profiles.values.first { $0.handle == normalized } + } + + func deleteAgentProfile(id: String) throws { + profiles.removeValue(forKey: id) + } +} diff --git a/ui/SharedAgentRuntime/Conversation/ConversationPipelineToolInterception.swift b/ui/SharedAgentRuntime/Conversation/ConversationPipelineToolInterception.swift index 523959ea..6f19bae2 100644 --- a/ui/SharedAgentRuntime/Conversation/ConversationPipelineToolInterception.swift +++ b/ui/SharedAgentRuntime/Conversation/ConversationPipelineToolInterception.swift @@ -3,7 +3,6 @@ import MCP import MCPClient import MemorySystem import PolicyRuntime -import MCPServer import AppEvents import PolicyUserInteraction import LLMAgentClient diff --git a/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift b/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift index 38f040b8..aef68514 100644 --- a/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift +++ b/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift @@ -4,7 +4,6 @@ import AgentRuntime import DBRepository import LLMAgentClient import MCPClient -import MCPServer import MemorySystem import PolicyRuntime import Structure diff --git a/ui/SharedAgentRuntime/Support/LLM/LLMModelSettings.swift b/ui/SharedAgentRuntime/Support/LLM/LLMModelSettings.swift index ff188535..f0a737a2 100644 --- a/ui/SharedAgentRuntime/Support/LLM/LLMModelSettings.swift +++ b/ui/SharedAgentRuntime/Support/LLM/LLMModelSettings.swift @@ -1,7 +1,6 @@ import Combine import Foundation import LLMAgentClient -import MCPServer import DBRepository import Structure diff --git a/ui/SharedAgentRuntime/Support/PluginCredentialCatalog.swift b/ui/SharedAgentRuntime/Support/PluginCredentialCatalog.swift index 725d5cf4..d729f188 100644 --- a/ui/SharedAgentRuntime/Support/PluginCredentialCatalog.swift +++ b/ui/SharedAgentRuntime/Support/PluginCredentialCatalog.swift @@ -1,4 +1,3 @@ -import DBRepository import Foundation import Plugin import Structure @@ -6,14 +5,14 @@ import Structure enum PluginCredentialCatalog { static func secretDescriptors( pluginID: String, - repository: DBRepository + repository: any PluginFactoryManifestCatalog ) async -> [PluginSecretDescriptor] { let manifests = (try? await repository.listLatestPluginFactoryManifests()) ?? [] let json = manifests.first(where: { $0.pluginID == pluginID })?.manifestJSON return PluginSecretField.resolvedDescriptors(pluginID: pluginID, fromManifestJSON: json) } - static func connectorPluginIDs(repository: DBRepository) async -> [String] { + static func connectorPluginIDs(repository: any PluginFactoryManifestCatalog) async -> [String] { let manifests = (try? await repository.listLatestPluginFactoryManifests()) ?? [] return manifests.compactMap { row in AgentPluginManifest.isConnector(manifestJSON: row.manifestJSON) ? row.pluginID : nil @@ -21,7 +20,7 @@ enum PluginCredentialCatalog { .sorted() } - static func pluginsWithSecrets(repository: DBRepository) async -> [PluginCredentialGroup] { + static func pluginsWithSecrets(repository: any PluginFactoryManifestCatalog) async -> [PluginCredentialGroup] { let manifests = (try? await repository.listLatestPluginFactoryManifests()) ?? [] return manifests.compactMap { row -> PluginCredentialGroup? in let secrets = PluginSecretField.resolvedDescriptors( @@ -43,21 +42,3 @@ enum PluginCredentialCatalog { } } } - -struct PluginCredentialGroup: Equatable, Identifiable { - let pluginID: String - let isConnector: Bool - let secrets: [PluginSecretDescriptor] - - var id: String { pluginID } - - var displayName: String { - pluginID - .split(separator: "-") - .map { part in - let lower = part.lowercased() - return lower.prefix(1).uppercased() + lower.dropFirst() - } - .joined(separator: " ") - } -} diff --git a/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift b/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift index 8488f4c6..148cdfc1 100644 --- a/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift +++ b/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift @@ -1,7 +1,6 @@ import Foundation import DBRepository import LLMAgentClient -import MCPServer import Plugin import Structure diff --git a/ui/SharedAgentRuntime/Support/Secrets/PluginDeclaredSecretAttacher.swift b/ui/SharedAgentRuntime/Support/Secrets/PluginDeclaredSecretAttacher.swift index 205cd4c3..ffe0bdc4 100644 --- a/ui/SharedAgentRuntime/Support/Secrets/PluginDeclaredSecretAttacher.swift +++ b/ui/SharedAgentRuntime/Support/Secrets/PluginDeclaredSecretAttacher.swift @@ -1,5 +1,4 @@ import Foundation -import MCPServer import Structure /// Attaches declared plugin Keychain secrets to host HTTP. Values never enter the guest. diff --git a/ui/ui/AgentProfiles/AgentProfileStore.swift b/ui/ui/AgentProfiles/AgentProfileStore.swift index 227b2a92..2bddf45e 100644 --- a/ui/ui/AgentProfiles/AgentProfileStore.swift +++ b/ui/ui/AgentProfiles/AgentProfileStore.swift @@ -1,5 +1,4 @@ import Combine -import DBRepository import Foundation import LLMAgentClient import Structure @@ -11,7 +10,7 @@ final class AgentProfileStore: ObservableObject { @Published private(set) var profiles: [AgentProfile] = [] @Published private(set) var lastError: String? - private var repository: DBRepository? + private var repository: (any AgentProfileCatalog)? var enabledProfiles: [AgentProfile] { profiles.filter(\.isEnabled) @@ -22,7 +21,7 @@ final class AgentProfileStore: ObservableObject { ?? enabledProfiles.first } - func configure(repository: DBRepository) async { + func configure(repository: any AgentProfileCatalog) async { self.repository = repository await reload() } @@ -84,7 +83,7 @@ final class AgentProfileStore: ObservableObject { await reload() } - private func ensureBuiltins(repository: DBRepository) async throws { + private func ensureBuiltins(repository: any AgentProfileCatalog) async throws { for profile in try AgentProfileBuiltinFactory.all() { try await repository.upsertAgentProfile(profile) } diff --git a/ui/uiTests/uiTests.swift b/ui/uiTests/uiTests.swift index 6fb872c0..6287904a 100644 --- a/ui/uiTests/uiTests.swift +++ b/ui/uiTests/uiTests.swift @@ -158,15 +158,6 @@ import DBRepository ) } - @Test func pluginCredentialGroupUsesHumanDisplayName() { - let group = PluginCredentialGroup( - pluginID: "slack-connection", - isConnector: true, - secrets: [PluginSecretDescriptor(id: "bot_token", label: "Bot Token", kind: "token")] - ) - #expect(group.displayName == "Slack Connection") - } - @MainActor @Test func keychainModeStillPrefersKeychain() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) let uiFolder = root.appendingPathComponent("ui", isDirectory: true)