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 @@ -138,3 +138,5 @@ public extension DBRepository {
)
}
}

extension DBRepository: AgentProfileCatalog {}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -159,7 +159,7 @@ public extension DBRepository {
continue
}
rows.append(
(
PluginFactoryManifestRecord(
pluginID: String(cString: id),
version: String(cString: version),
manifestJSON: String(cString: manifest),
Expand Down Expand Up @@ -195,3 +195,5 @@ public extension DBRepository {
try savePluginFactoryRelease(release)
}
}

extension DBRepository: PluginFactoryManifestCatalog {}
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Foundation
import MCPServer
import Structure

public struct HarnessSecretAttacher: HostHTTPSecretAttacher {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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 }!
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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]
}
5 changes: 5 additions & 0 deletions packages/Structure/Sources/Plugin/HTTP/HostHTTPAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import MCP
import MCPClient
import MemorySystem
import PolicyRuntime
import MCPServer
import AppEvents
import PolicyUserInteraction
import LLMAgentClient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import AgentRuntime
import DBRepository
import LLMAgentClient
import MCPClient
import MCPServer
import MemorySystem
import PolicyRuntime
import Structure
Expand Down
1 change: 0 additions & 1 deletion ui/SharedAgentRuntime/Support/LLM/LLMModelSettings.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Combine
import Foundation
import LLMAgentClient
import MCPServer
import DBRepository
import Structure

Expand Down
25 changes: 3 additions & 22 deletions ui/SharedAgentRuntime/Support/PluginCredentialCatalog.swift
Original file line number Diff line number Diff line change
@@ -1,27 +1,26 @@
import DBRepository
import Foundation
import Plugin
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
}
.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(
Expand All @@ -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: " ")
}
}
1 change: 0 additions & 1 deletion ui/SharedAgentRuntime/Support/PluginFactoryModels.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Foundation
import DBRepository
import LLMAgentClient
import MCPServer
import Plugin
import Structure

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Foundation
import MCPServer
import Structure

/// Attaches declared plugin Keychain secrets to host HTTP. Values never enter the guest.
Expand Down
Loading
Loading