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
8 changes: 7 additions & 1 deletion desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ On macOS, first launch imports existing `config.json` with a resumable journal a

Public tools use a readable connector prefix derived by Python, for example `Dinero_get_invoice`. On backend startup, existing generated `desktop_<UUID>` and `import_<hash>` prefixes are repaired without reconnecting or recreating the connector. Custom prefixes remain unchanged. Refresh connected agents' tool catalogs after upgrading; old generated tool names are not retained as aliases. Duplicate names encountered during import or repair receive numeric suffixes. New connections use the shared backend's publication collision checks; give separate accounts descriptive names such as `Dinero Work` and `Dinero Personal` when their tool names overlap.

Settings → Advanced reads the running exposure mode and administrator MCP feature state from the authenticated backend. Choose `gateway` (default/recommended: `search_tools` + `execute_tool`), `flat` (individual enabled tools), or `codemode` (`search_tools` + `execute_code`). Uploaded-file tools currently require `flat`, which can increase startup/context size. Administrator MCP is off by default; enabling requires confirmation because every agent using the Desktop token can read and change connector configuration, activation, policy and grants. **Save and restart connection** durably saves both values in the profile's backend-owned `desktop-v2.json`, stops the backend, starts it and verifies health and effective settings. Pending values are not reported as active before verification. Connected MCP clients may need a new session or tool-catalog refresh. A failed restart retains saved settings and profile data: retry the action, or use Start in General settings after resolving the reported problem. Never delete the profile/database or credentials as a restart workaround.

Exposure selection does not add connector installation or native setup screens: the desktop editor still manages downstream MCP connections only, and built-in catalog connectors remain unavailable. OpenAPI connectors provisioned through the shared administration API require `codemode` for MCP discovery and execution; they do not appear in the native connector inventory. Administrator MCP does not install catalog connectors.

When the connection is stopped in General settings, start it there before saving Advanced changes. Advanced restart recovery is available offline only for an already-saved draft after an unverified restart.

Closing the UI leaves the background gateway running. Settings can stop or restart it. Development and release profiles have separate data and vault namespaces (`app.umbod.desktop.dev` and `app.umbod.desktop`). Builds do not migrate, reset, or install app data. Release apps check the published desktop manifest on launch and at most once daily while open. Settings provides a manual update check, an automatic-check preference, release notes, and a guided installer download/quit flow. Users can defer or skip a version; manual checks show skipped versions again. Development builds default to manual checks, and test-credential app sessions never check online. Updates are not installed automatically, and there is no login supervisor.

OAuth supports native public clients with loopback callbacks, discovery, PKCE and refresh. Real OAuth providers are not certified. Legacy standalone SSE upstreams, upstream resources and prompts, sampling, and tool argument-level policies are unsupported. Desktop does not claim the Kubernetes edition's organization identity or group administration capabilities.
Expand All @@ -52,7 +58,7 @@ Older installed versions that require a Windows asset in update metadata cannot
## Code and release index

- [`python/`](python/README.md): macOS host, transport adapters, Keychain, migration and tests; reuses `umbod/api` and `packages/umbod-sdk`.
- `native/`: SwiftUI app and Swift contract/lifecycle tests; Settings provides compact update controls and generic stdio/HTTP connection details, with the bearer token masked and available only through an explicit copy action. Connector authentication stays optional and credentials remain in Keychain; `UmbodCore/ConnectorEditor.swift` owns the actual editor submission and retry state; `UmbodCore/ToolPermissionQueue.swift` owns optimistic permission edits, ordered save state, and stale-refresh protection; `UmbodCore/DesktopUpdate.swift` validates update metadata and `Umbod/UpdatesView.swift` owns update preferences and the install guidance.
- `native/`: SwiftUI app and Swift contract/lifecycle tests; `UmbodCore/AdvancedSettings.swift` owns backend-reported pending/effective settings, confirmation and verified restart/recovery, while `Umbod/AdvancedSettingsView.swift` presents Settings → Advanced; Settings provides compact update controls and generic stdio/HTTP connection details, with the bearer token masked and available only through an explicit copy action. Connector authentication stays optional and credentials remain in Keychain; `UmbodCore/ConnectorEditor.swift` owns the actual editor submission and retry state; `UmbodCore/ToolPermissionQueue.swift` owns optimistic permission edits, ordered save state, and stale-refresh protection; `UmbodCore/DesktopUpdate.swift` validates update metadata and `Umbod/UpdatesView.swift` owns update preferences and the install guidance.
- `scripts/`: local builds, packaging, isolated fixtures, and release policy/metadata.
- `VERSION`: independent desktop version; the single source for bundle and package versions.
- [Release procedure](docs/RELEASING.md), [manifest application](docs/APPLY-MANIFEST.md), and [import provenance and audit](docs/PROVENANCE.md).
Expand Down
79 changes: 79 additions & 0 deletions desktop/native/Sources/Umbod/AdvancedSettingsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import SwiftUI
import UmbodCore

struct AdvancedSettingsView: View {
@EnvironmentObject var model: AppModel
@ObservedObject var settings: AdvancedSettingsModel
@State private var loadError = ""
var body: some View {
Panel {
VStack(alignment: .leading, spacing: 16) {
Text("Connector tool exposure").font(.headline)
if let effective = settings.effective, model.online {
Text("Running: \(effective.exposureMode.rawValue) · Administrator MCP: \(effective.administratorEnabled ? "enabled" : "disabled")")
.font(.callout).foregroundStyle(Brand.muted)
} else {
Text("No verified running settings. Start the connection or retry the restart.").foregroundStyle(Brand.muted)
}
Picker("Exposure mode", selection: $settings.draft.exposureMode) {
ForEach(ConnectorExposureMode.allCases, id: \.self) { mode in
Text(mode.rawValue).tag(mode)
}
}.disabled(settings.saved == nil || settings.working || model.busy || !model.online)
Text(settings.draft.exposureMode.explanation).font(.callout).foregroundStyle(Brand.muted)
Text("Exposure changes alter the MCP tool list. Connected clients may need a new session or tool-catalog refresh after restart.")
.font(.callout).foregroundStyle(Brand.muted)
Divider()
Toggle("Enable administrator MCP tools", isOn: Binding(
get: { settings.draft.administratorEnabled },
set: { settings.requestAdministrator($0) }
)).disabled(settings.saved == nil || settings.working || model.busy || !model.online)
Text("Every MCP client using the Desktop agent token receives this access. Connected agents can read and change connector configuration, activation, policy and grants.")
.font(.callout).foregroundStyle(Brand.muted)
if settings.hasPendingChanges {
Label(settings.draft != settings.saved ? "Unsaved changes — restart required" : "Saved changes — not yet verified active", systemImage: "arrow.clockwise")
.font(.callout)
}
if !loadError.isEmpty { Text(loadError).foregroundStyle(Brand.danger) }
if !settings.error.isEmpty { Text(settings.error).foregroundStyle(Brand.danger).textSelection(.enabled) }
if !settings.notice.isEmpty { Text(settings.notice).foregroundStyle(Brand.accent) }
HStack {
Button("Save and restart connection") {
Task {
model.busy = true
defer { model.busy = false }
await settings.saveAndRestart(request: { try await model.backend.request($0) }, stop: {
try await model.backend.shutdown()
model.online = false
}, start: {
try await model.backend.start(directory: model.dataDirectory, persistent: model.dataDirectory == nil, testCredentials: CommandLine.arguments.contains("--test-credentials"))
})
model.online = model.backend.isRunning
if settings.error.isEmpty { try? await model.refresh() }
}
}.disabled(model.busy || !settings.canSaveAndRestart(isOnline: model.online))
if settings.working { ProgressView().controlSize(.small) }
if model.online {
Button("Reload settings") { Task { await reload() } }.disabled(model.busy || settings.working)
}
}
}
}
.task { if model.online && !settings.working && !model.busy { await reload() } }
.alert("Enable administrator MCP tools?", isPresented: Binding(
get: { settings.confirmAdministrator },
set: { if !$0 { settings.cancelAdministrator() } }
)) {
Button("Cancel", role: .cancel) { settings.cancelAdministrator() }
Button("Enable administrator tools", role: .destructive) { settings.acceptAdministrator() }
} message: {
Text("Administrator MCP lets connected agents read and change Umbod's connector configuration, activation, policy and grants. Every agent using this Desktop connection will receive this access after restart.")
}
}
private func reload() async {
do {
try await settings.load(preserveDraft: true) { try await model.backend.request($0) }
loadError = ""
} catch { loadError = "Could not read backend settings. Start or restart the connection in General settings, then reload. \(error.localizedDescription)" }
}
}
13 changes: 9 additions & 4 deletions desktop/native/Sources/Umbod/UmbodApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import UmbodCore
@MainActor final class AppModel: ObservableObject {
static let shared = AppModel()
let backend = Backend()
let advancedSettings = AdvancedSettingsModel()
let updates = UpdateModel()
@Published var activation = ActivationState([:])
@Published var diagnostics: [String: Any] = [:]
Expand Down Expand Up @@ -511,10 +512,10 @@ struct ConnectionView: View {
}
}
enum SettingsSection: String, CaseIterable {
case general = "General", mcp = "MCP"
var symbol: String { self == .general ? "slider.horizontal.3" : "point.3.connected.trianglepath.dotted" }
case general = "General", mcp = "MCP", advanced = "Advanced"
var symbol: String { self == .advanced ? "gearshape.2" : (self == .general ? "slider.horizontal.3" : "point.3.connected.trianglepath.dotted") }
var subtitle: String {
self == .general ? "Keep Umbod up to date and ready to use." : "Connect your agent to Umbod. Choose either method below."
self == .advanced ? "Backend settings apply together after an explicit restart." : (self == .general ? "Keep Umbod up to date and ready to use." : "Connect your agent to Umbod. Choose either method below.")
}
}
struct SettingsMenuButton: View {
Expand Down Expand Up @@ -582,7 +583,11 @@ struct SettingsView: View {
}
Text(model.settingsSection.subtitle).font(.system(size: 15)).foregroundStyle(Brand.muted)
StatusMessages()
if model.settingsSection == .mcp { ConnectionView() } else { GeneralSettingsView() }
switch model.settingsSection {
case .mcp: ConnectionView()
case .general: GeneralSettingsView()
case .advanced: AdvancedSettingsView(settings: model.advancedSettings)
}
}.padding(40).frame(maxWidth: 900, alignment: .leading).frame(maxWidth: .infinity, alignment: .leading)
}.background(Brand.canvas)
}.modifier(BrandAppearance())
Expand Down
2 changes: 2 additions & 0 deletions desktop/native/Sources/UmbodCore/AdministrationClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ import Foundation
if let scopes = body["scopes"] { payload["scopes"] = scopes }
}
return try await send("POST", path + "/" + operation, payload)
case "settings_get": return try await send("GET", "/desktop/settings")
case "settings_save": return try await send("PUT", "/desktop/settings", body["settings"] as? [String: Any] ?? [:])
case "health": return try await send("GET", "/desktop/health")
case "shutdown": return try await send("POST", "/desktop/shutdown")
case "status": return try await status()
Expand Down
99 changes: 99 additions & 0 deletions desktop/native/Sources/UmbodCore/AdvancedSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import Foundation
import Combine

public enum ConnectorExposureMode: String, Codable, CaseIterable, Sendable {
case gateway, flat, codemode
public var explanation: String {
switch self {
case .gateway: return "Recommended. Exposes search_tools and execute_tool. Uploaded-file connector tools are currently unsupported."
case .flat: return "Exposes each enabled tool directly. Required for uploaded-file tools; can increase startup time and context size."
case .codemode: return "Exposes search_tools and execute_code. Uploaded-file connector tools are currently unsupported."
}
}
}

public struct DesktopBackendSettings: Codable, Equatable, Sendable {
public var exposureMode: ConnectorExposureMode = .gateway
public var administratorEnabled = false
enum CodingKeys: String, CodingKey {
case exposureMode = "connector_tool_exposure_mode"
case administratorEnabled = "mcp_administrator_enabled"
}
public init() {}
public init(_ value: [String: Any]) throws {
self = try JSONDecoder().decode(Self.self, from: JSONSerialization.data(withJSONObject: value))
}
public var payload: [String: Any] {
["connector_tool_exposure_mode": exposureMode.rawValue, "mcp_administrator_enabled": administratorEnabled]
}
}

/// Only backend responses establish saved/effective state; never UserDefaults.
@MainActor public final class AdvancedSettingsModel: ObservableObject {
@Published public var draft = DesktopBackendSettings() {
didSet { notice = "" }
}
@Published public private(set) var saved: DesktopBackendSettings?
@Published public private(set) var effective: DesktopBackendSettings?
@Published public private(set) var confirmAdministrator = false
@Published public private(set) var working = false
@Published public private(set) var error = ""
@Published public private(set) var notice = ""
public var hasPendingChanges: Bool { saved != nil && (draft != saved || saved != effective) }
public init() {}
public func canSaveAndRestart(isOnline: Bool) -> Bool {
guard saved != nil, !working else { return false }
return isOnline ? hasPendingChanges : effective == nil && draft == saved
}
public func requestAdministrator(_ enabled: Bool) {
if enabled && !draft.administratorEnabled { confirmAdministrator = true }
else { draft.administratorEnabled = enabled }
}
public func acceptAdministrator() { draft.administratorEnabled = true; confirmAdministrator = false }
public func cancelAdministrator() { confirmAdministrator = false }
private func snapshot(_ response: [String: Any]) throws -> (DesktopBackendSettings, DesktopBackendSettings) {
guard let pending = response["pending"] as? [String: Any], let running = response["effective"] as? [String: Any] else {
throw ContractError.message("Backend settings are unavailable. Restart the connection and retry.")
}
return try (DesktopBackendSettings(pending), DesktopBackendSettings(running))
}
public func load(preserveDraft: Bool = false, request: ([String: Any]) async throws -> [String: Any]) async throws {
let retainDraft = preserveDraft && saved != nil && draft != saved
let (pending, running) = try snapshot(await request(["action": "settings_get"]))
saved = pending; effective = running
if !retainDraft { draft = pending }
}
public func saveAndRestart(
request: ([String: Any]) async throws -> [String: Any],
stop: () async throws -> Void,
start: () async throws -> Void
) async {
guard !working, saved != nil else { return }
working = true; error = ""; notice = ""
defer { working = false }
var persisted = false
do {
// After a failed start the backend is offline, but the saved draft is
// already durable. Retry startup without requiring an offline API.
if effective != nil || saved != draft {
let (pending, running) = try snapshot(await request(["action": "settings_save", "settings": draft.payload]))
saved = pending; effective = running
guard pending == draft else { throw ContractError.message("Backend did not save the selected settings.") }
}
persisted = true
try await stop()
effective = nil
try await start()
let health = try await request(["action": "health"])
guard health["status"] as? String == "ok" else { throw ContractError.message("Backend health check failed.") }
let (pending, running) = try snapshot(await request(["action": "settings_get"]))
guard pending == saved, running == saved else { throw ContractError.message("Running backend settings do not match the saved settings.") }
saved = pending; effective = running
notice = "Settings active. Connected MCP clients may need a new session or tool-catalog refresh."
} catch {
self.error = persisted
? "Settings are saved, but restart was not verified. Retry Save and restart connection, or Start in General settings. Your connections, credentials and permissions are retained. \(error.localizedDescription)"
: "Could not verify that settings were saved. The connection was not restarted. Reload settings to check the saved values, then retry. \(error.localizedDescription)"
}
}
}
Loading
Loading