diff --git a/macos/OpenBridge/Agent/AgentSessionManager.swift b/macos/OpenBridge/Agent/AgentSessionManager.swift index 6c38b81..7a514a2 100644 --- a/macos/OpenBridge/Agent/AgentSessionManager.swift +++ b/macos/OpenBridge/Agent/AgentSessionManager.swift @@ -159,6 +159,17 @@ final class AgentSessionManager { func refreshConnectorConfiguration() { isVMReady = true + vmLoadError = nil + Task { [weak self] in + guard let self else { return } + do { + _ = try await ensureRuntimeReady() + await refreshLoadedSessionRuntimeConfiguration() + } catch { + vmLoadError = error + logger.warning("Failed to refresh local connector configuration: \(error.localizedDescription)") + } + } } func restartConnector() { @@ -168,6 +179,25 @@ final class AgentSessionManager { isVMReady = true } + func applyLocalEnvironmentPermissionModeChange(_ mode: LocalEnvironmentPermissionMode) { + connector?.applyPermissionModeChange(mode) + Task { [weak self] in + guard let self else { return } + await refreshLoadedSessionRuntimeConfiguration() + } + } + + func reloadAIProviderConfiguration() async { + await BridgeAIProviderRegistry.registerProviders() + await refreshLoadedSessionRuntimeConfiguration() + } + + private func refreshLoadedSessionRuntimeConfiguration() async { + for session in sessions.values { + await session.refreshRuntimeConfiguration() + } + } + private func disconnectVMConnector(shutdownBridge: Bool = false) { guard vmConnector != nil || embeddedVMBridge != nil else { return } vmConnector?.disconnect() diff --git a/macos/OpenBridge/Agent/BridgeAIProviderRegistry.swift b/macos/OpenBridge/Agent/BridgeAIProviderRegistry.swift index a92878f..a13c3da 100644 --- a/macos/OpenBridge/Agent/BridgeAIProviderRegistry.swift +++ b/macos/OpenBridge/Agent/BridgeAIProviderRegistry.swift @@ -51,11 +51,39 @@ enum BridgeAIProviderRegistry { static func selectedModel() async -> Model { let settings = await BridgeAIProviderSecretStore.readSettings() - return runtimeModel( - provider: settings.selectedModelProvider, - id: settings.selectedModelID, + return selectedModel(settings: settings) + } + + static func selectedModel(settings: BridgeAIProviderSettings) -> Model { + let selected = enabledModel( + runtimeModel( + provider: settings.selectedModelProvider, + id: settings.selectedModelID, + settings: settings + ), settings: settings - ) ?? defaultModel(settings: settings) + ) + return selected ?? defaultModel(settings: settings) + } + + static func selectedDisplayModel(settings: BridgeAIProviderSettings) -> Model { + let selected = enabledModel( + displayModel( + provider: settings.selectedModelProvider, + id: settings.selectedModelID, + settings: settings + ), + settings: settings + ) + return selected ?? defaultModel(settings: settings) + } + + private static func enabledModel(_ model: Model?, settings: BridgeAIProviderSettings) -> Model? { + model.flatMap { model in + BridgeAIProvider.provider(for: model).flatMap { provider in + settings[provider].isEnabled ? model : nil + } + } } static func runtimeModel(provider: String, id: String) async -> Model? { @@ -92,10 +120,14 @@ enum BridgeAIProviderRegistry { } static func displayModel(provider: String, id: String) -> Model? { + displayModel(provider: provider, id: id, settings: BridgeAIProviderSettings()) + } + + private static func displayModel(provider: String, id: String, settings: BridgeAIProviderSettings) -> Model? { if provider == BridgeAIProvider.openAIChatCompletions.rawValue { return openAIChatCompletionsModel( id: id, - config: BridgeAIProviderSettings()[.openAIChatCompletions] + config: settings[.openAIChatCompletions] ) } return ModelsCatalog.model(provider: provider, id: id) diff --git a/macos/OpenBridge/Agent/LocalRuntime/LocalAgentSession.swift b/macos/OpenBridge/Agent/LocalRuntime/LocalAgentSession.swift index c7494a6..713cce5 100644 --- a/macos/OpenBridge/Agent/LocalRuntime/LocalAgentSession.swift +++ b/macos/OpenBridge/Agent/LocalRuntime/LocalAgentSession.swift @@ -48,6 +48,7 @@ final class LocalAgentSession: Identifiable { private var localBackgroundManager: BackgroundTaskManager? private var localAgentUnsubscribe: Unsubscribe? private var localRunTask: Task? + private var pendingRuntimeConfigurationRefresh = false private var localAssistantMessageID: String? private var localAssistantText = "" private var localToolStates: [String: AssistantToolCallState] = [:] @@ -184,7 +185,6 @@ final class LocalAgentSession: Identifiable { let now = Int64(Date().timeIntervalSince1970) listCreatedAt = listCreatedAt ?? now listUpdatedAt = listUpdatedAt ?? now - _ = try await ensureLocalAgent() } func teardown() async { @@ -270,6 +270,7 @@ final class LocalAgentSession: Identifiable { AgentSessionManager.shared.clearLocalPermission(sessionId: sessionID) updateLocalAssistantState(phase: "cancelled", isStreaming: false) onSessionFinished?("cancelled", nil) + refreshRuntimeConfigurationAfterRunIfNeeded() return true } @@ -321,24 +322,41 @@ final class LocalAgentSession: Identifiable { // MARK: - Local KWWK Agent - private func ensureLocalAgent() async throws -> Agent { - if let localAgent { - return localAgent + func refreshRuntimeConfiguration() async { + guard let localAgent else { return } + guard !isProcessing else { + pendingRuntimeConfigurationRefresh = true + return } + pendingRuntimeConfigurationRefresh = false + localAgent.state.model = await BridgeAIProviderRegistry.selectedModel() + localAgent.state.systemPrompt = await makeLocalAgentSystemPrompt() + } - await BridgeAIProviderRegistry.registerProviders() - let backgroundManager = BackgroundTaskManager() + private func makeLocalAgentSystemPrompt() async -> String { let skillManager = SkillManager.shared let cwd = skillManager.skillDirs.workspace.path let memoryPrompt = await MemoryRepository.shared.systemPromptSection() let environmentInventory = try? await AgentSessionManager.shared.localEnvironmentSystemPromptSection() - let systemPrompt = OpenBridgeSystemPromptBuilder.build( + return OpenBridgeSystemPromptBuilder.build( cwd: cwd, skills: skillManager.skills, memory: memoryPrompt, computerUsePrompt: OpenBridgeComputerUseAgent.systemPromptWithStartupInventory(clientStore: computerUseClientStore), environmentInventory: environmentInventory ) + } + + private func ensureLocalAgent() async throws -> Agent { + if let localAgent { + return localAgent + } + + await BridgeAIProviderRegistry.registerProviders() + let backgroundManager = BackgroundTaskManager() + let skillManager = SkillManager.shared + let cwd = skillManager.skillDirs.workspace.path + let systemPrompt = await makeLocalAgentSystemPrompt() localBackgroundManager = backgroundManager let config = await CodingAgentConfig( @@ -472,12 +490,21 @@ final class LocalAgentSession: Identifiable { AgentSessionManager.shared.clearLocalPermission(sessionId: sessionID) updateLocalAssistantState(phase: lastFinishState ?? "completed", isStreaming: false) onSessionFinished?(lastFinishState ?? "completed", error?.localizedDescription) + refreshRuntimeConfigurationAfterRunIfNeeded() Task { [weak self] in guard let self else { return } await refreshWorkspaceState() } } + private func refreshRuntimeConfigurationAfterRunIfNeeded() { + guard pendingRuntimeConfigurationRefresh else { return } + Task { [weak self] in + guard let self else { return } + await refreshRuntimeConfiguration() + } + } + private func requestComputerUseStartConfirmation(apps: [String]) async -> PermissionConfirmationReply { let confirmationId = "local-\(UUID().uuidString)" let message = makeComputerUsePermissionRequestMessage(confirmationId: confirmationId, apps: apps) diff --git a/macos/OpenBridge/Agent/LocalRuntime/LocalRuntimeConnector.swift b/macos/OpenBridge/Agent/LocalRuntime/LocalRuntimeConnector.swift index b2931e0..27cda52 100644 --- a/macos/OpenBridge/Agent/LocalRuntime/LocalRuntimeConnector.swift +++ b/macos/OpenBridge/Agent/LocalRuntime/LocalRuntimeConnector.swift @@ -39,6 +39,7 @@ private struct PendingConfirmation { var continuations: [CheckedContinuation] let sessionId: String? let grantKey: PermissionGrantKey? + let respondsToPermissionMode: Bool } private enum PermissionReplyReason { @@ -149,6 +150,17 @@ final class LocalRuntimeConnector { killAllProcesses() } + func applyPermissionModeChange(_ mode: LocalEnvironmentPermissionMode) { + guard environmentKind == .localMacOS else { return } + + switch mode { + case .default: + revokeGrantedPermissions() + case .fullAccess: + approvePendingHostAccessPermissions() + } + } + private func currentArch() -> String { #if arch(arm64) return "arm64" @@ -497,6 +509,19 @@ private extension LocalRuntimeConnector { grantedPermissionKeys = Set(grantedPermissionKeys.filter { $0.sessionKey != sessionKey }) } + func revokeGrantedPermissions() { + grantedPermissionKeys.removeAll() + } + + func approvePendingHostAccessPermissions() { + let confirmationIDs = pendingConfirmations.compactMap { id, pending in + pending.respondsToPermissionMode ? id : nil + } + for confirmationID in confirmationIDs { + resolveConfirmation(id: confirmationID, approved: true) + } + } + /// Injects a permission_request message into the session that triggered the /// request (identified by sessionId) and suspends until the user clicks /// Allow or Deny in the chat UI. Returns false immediately if no session @@ -599,7 +624,8 @@ private extension LocalRuntimeConnector { pendingConfirmations[confirmationId] = PendingConfirmation( continuations: [continuation], sessionId: sessionId, - grantKey: grantKey + grantKey: grantKey, + respondsToPermissionMode: computerUseStart == nil ) if let grantKey { pendingPermissionConfirmationIDsByGrantKey[grantKey] = confirmationId diff --git a/macos/OpenBridge/Agent/LocalRuntime/OpenBridgeSystemPromptBuilder.swift b/macos/OpenBridge/Agent/LocalRuntime/OpenBridgeSystemPromptBuilder.swift index 87f9d74..ac4891e 100644 --- a/macos/OpenBridge/Agent/LocalRuntime/OpenBridgeSystemPromptBuilder.swift +++ b/macos/OpenBridge/Agent/LocalRuntime/OpenBridgeSystemPromptBuilder.swift @@ -39,8 +39,9 @@ enum OpenBridgeSystemPromptBuilder { - Treat environment="sandbox" as the default environment for file reads, writes, commands, and project work. - Do not switch to or target environment="local" on your own initiative. Use environment="local" only when the user explicitly asks for direct host work or when sandbox cannot complete the task. - environment="local" is protected because it operates directly on this Mac. Host writes, host commands, and sensitive host paths require explicit user approval. - - Local permission is temporary for the current task execution. Do not assume a past approval applies to a later user request. - - Before using bash, write, or edit in environment="local", call request_permission(environment="local", description="...") with a clear, specific description of what you plan to do so the user can make an informed decision. + \(localPermissionModeInstruction()) + \(localPermissionPersistenceInstruction()) + \(localPermissionRequestInstruction()) - If local permission is pending and you can still make progress in sandbox, continue there. If blocked on approval, wait and explain what you are waiting for. - Do not operate on sandbox and local filesystems in the same task unless the user explicitly asks for that handoff. Choose one environment for filesystem work. - After completing sandbox file operations, call current_changes to review staged sandbox changes before finishing. @@ -59,6 +60,33 @@ enum OpenBridgeSystemPromptBuilder { """ } + private static func localPermissionModeInstruction() -> String { + switch SettingsManager.shared.localEnvironmentPermissionMode { + case .default: + "- Current local permission mode: Default. Host writes, host commands, and sensitive host paths require request_permission before use." + case .fullAccess: + "- Current local permission mode: Full Access. Host writes, host commands, and sensitive host paths are already approved for this app session; do not call request_permission solely for local permission, but still prefer sandbox unless local access is required." + } + } + + private static func localPermissionPersistenceInstruction() -> String { + switch SettingsManager.shared.localEnvironmentPermissionMode { + case .default: + "- Local permission is temporary for the current task execution. Do not assume a past approval applies to a later user request." + case .fullAccess: + "- Full Access remains available only while the user keeps this app setting enabled. If the setting changes back to Default, request local permission again before protected host access." + } + } + + private static func localPermissionRequestInstruction() -> String { + switch SettingsManager.shared.localEnvironmentPermissionMode { + case .default: + "- Before using bash, write, or edit in environment=\"local\", call request_permission(environment=\"local\", description=\"...\") with a clear, specific description of what you plan to do so the user can make an informed decision." + case .fullAccess: + "- Before using bash, write, or edit in environment=\"local\", confirm local access is truly required; request_permission is not needed while Full Access is active." + } + } + private static func skillSection(skills: [Skill]) -> String { let entries = skills .filter { !$0.disabled && $0.visibility != .hidden } diff --git a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift index 9e0f71d..5b5e3dd 100644 --- a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift +++ b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift @@ -166,16 +166,7 @@ extension ChatEditorViewModel { selectedModelID = settings.selectedModelID return } - let selected = BridgeAIProviderRegistry.displayModel( - provider: settings.selectedModelProvider, - id: settings.selectedModelID - ) - .flatMap { model in - BridgeAIProvider.provider(for: model).flatMap { provider in - settings[provider].isEnabled ? model : nil - } - } - ?? BridgeAIProviderRegistry.defaultModel(settings: settings) + let selected = BridgeAIProviderRegistry.selectedDisplayModel(settings: settings) selectedModelProvider = selected.provider selectedModelID = selected.id } @@ -205,6 +196,7 @@ extension ChatEditorViewModel { SettingsManager.shared.localEnvironmentPermissionMode != mode else { return } SettingsManager.shared.localEnvironmentPermissionMode = mode + AgentSessionManager.shared.applyLocalEnvironmentPermissionModeChange(mode) } private func openAIProviderSettings() { diff --git a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift index 18e98a6..268c8fb 100644 --- a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift +++ b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift @@ -66,6 +66,8 @@ final class ChatEditorViewModel { var voicePendingWaveformPeak: Double = 0 @ObservationIgnored private var quoteFocusRequestCounter: Int = 0 + @ObservationIgnored + private var aiProviderSettingsCancellable: AnyCancellable? /// Skill selected for the current conversation (delegated to Chat) var selectedSkill: Skill? { @@ -125,6 +127,13 @@ final class ChatEditorViewModel { text: String = "" ) { self.text = text + aiProviderSettingsCancellable = NotificationCenter.default + .publisher(for: .aiProviderSettingsDidChange) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + await self?.loadSelectedModel() + } + } Task { await loadSelectedModel() } } diff --git a/macos/OpenBridge/Interface/Settings/AIProviders/AIProviderDetailView.swift b/macos/OpenBridge/Interface/Settings/AIProviders/AIProviderDetailView.swift index 2b1db3a..40f7e50 100644 --- a/macos/OpenBridge/Interface/Settings/AIProviders/AIProviderDetailView.swift +++ b/macos/OpenBridge/Interface/Settings/AIProviders/AIProviderDetailView.swift @@ -2,6 +2,12 @@ import Foundation import KWWKAI import SwiftUI +private struct AIProviderAuthSnapshot: Equatable { + var apiKey: String + var oauthAccessToken: String + var oauthRefreshToken: String +} + struct AIProviderDetailView: View { let provider: BridgeAIProvider @@ -517,6 +523,8 @@ struct AIProviderDetailView: View { defer { isSaving = false } do { + let previousSettings = await BridgeAIProviderSecretStore.readSettings() + let previousAuth = await authSnapshot() if config.authMethod == .apiKey { try await BridgeAIProviderSecretStore.saveSecret(apiKey, for: provider, kind: .apiKey) } else { @@ -539,9 +547,14 @@ struct AIProviderDetailView: View { } config.isEnabled = hasStoredAuth || config.isEnabled - var settings = await BridgeAIProviderSecretStore.readSettings() + var settings = previousSettings settings[provider] = config try await BridgeAIProviderSecretStore.saveSettings(settings) + await applyProviderConfigurationChange( + previousSettings: previousSettings, + updatedSettings: settings, + previousAuth: previousAuth + ) statusMessage = "Saved" await refreshUsage() } catch { @@ -570,6 +583,8 @@ struct AIProviderDetailView: View { } do { + let previousSettings = await BridgeAIProviderSecretStore.readSettings() + let previousAuth = await authSnapshot() let callbacks = OAuthLogin.Callbacks( onAuthURL: { url in Browser.open(url) @@ -610,9 +625,14 @@ struct AIProviderDetailView: View { config.baseURL = endpoint } - var settings = await BridgeAIProviderSecretStore.readSettings() + var settings = previousSettings settings[provider] = config try await BridgeAIProviderSecretStore.saveSettings(settings) + await applyProviderConfigurationChange( + previousSettings: previousSettings, + updatedSettings: settings, + previousAuth: previousAuth + ) statusMessage = "Signed in" await refreshUsage() } catch is CancellationError { @@ -628,6 +648,8 @@ struct AIProviderDetailView: View { private func resetProvider() async { errorMessage = nil do { + let previousSettings = await BridgeAIProviderSecretStore.readSettings() + let previousAuth = await authSnapshot() try await BridgeAIProviderSecretStore.saveSecret("", for: provider, kind: .apiKey) try await BridgeAIProviderSecretStore.saveSecret("", for: provider, kind: .oauthAccessToken) try await BridgeAIProviderSecretStore.saveSecret("", for: provider, kind: .oauthRefreshToken) @@ -644,15 +666,44 @@ struct AIProviderDetailView: View { } usageSnapshot = .unavailable - var settings = await BridgeAIProviderSecretStore.readSettings() + var settings = previousSettings settings[provider] = config try await BridgeAIProviderSecretStore.saveSettings(settings) + await applyProviderConfigurationChange( + previousSettings: previousSettings, + updatedSettings: settings, + previousAuth: previousAuth + ) statusMessage = "Provider reset" } catch { errorMessage = error.localizedDescription } } + private func applyProviderConfigurationChange( + previousSettings: BridgeAIProviderSettings, + updatedSettings: BridgeAIProviderSettings, + previousAuth: AIProviderAuthSnapshot + ) async { + let updatedAuth = await authSnapshot() + guard previousSettings != updatedSettings || previousAuth != updatedAuth else { return } + await AgentSessionManager.shared.reloadAIProviderConfiguration() + await MainActor.run { + NotificationCenter.default.post(name: .aiProviderSettingsDidChange, object: nil) + } + } + + private func authSnapshot() async -> AIProviderAuthSnapshot { + let apiKey = await BridgeAIProviderSecretStore.readSecret(for: provider, kind: .apiKey) + let oauthAccessToken = await BridgeAIProviderSecretStore.readSecret(for: provider, kind: .oauthAccessToken) + let oauthRefreshToken = await BridgeAIProviderSecretStore.readSecret(for: provider, kind: .oauthRefreshToken) + return AIProviderAuthSnapshot( + apiKey: apiKey, + oauthAccessToken: oauthAccessToken, + oauthRefreshToken: oauthRefreshToken + ) + } + private func oauthLogin(callbacks: OAuthLogin.Callbacks) async throws -> OAuthCredentials { switch provider { case .openAI: diff --git a/macos/OpenBridge/Interface/Settings/AIProviders/AIProvidersSettingsView.swift b/macos/OpenBridge/Interface/Settings/AIProviders/AIProvidersSettingsView.swift index 6886231..14c1e7e 100644 --- a/macos/OpenBridge/Interface/Settings/AIProviders/AIProvidersSettingsView.swift +++ b/macos/OpenBridge/Interface/Settings/AIProviders/AIProvidersSettingsView.swift @@ -37,6 +37,9 @@ struct AIProvidersSettingsView: View { guard newPath.isEmpty else { return } Task { await reload() } } + .onReceiveNotification(name: .aiProviderSettingsDidChange) { _ in + Task { await reload() } + } } private var header: some View { diff --git a/macos/OpenBridge/Notifications/NotificationNames.swift b/macos/OpenBridge/Notifications/NotificationNames.swift index cb19a70..78a721f 100644 --- a/macos/OpenBridge/Notifications/NotificationNames.swift +++ b/macos/OpenBridge/Notifications/NotificationNames.swift @@ -14,6 +14,7 @@ extension Notification.Name { /// Posted when a skill should be activated in chat. The notification's `object` should be a `SkillInfo`. static let skillActivationRequested = Notification.Name("skillActivationRequested") static let skillInventoryDidChange = Notification.Name("skillInventoryDidChange") + static let aiProviderSettingsDidChange = Notification.Name("aiProviderSettingsDidChange") // MARK: - Shortcuts