From e0fb03a94e9750b9bde6d40a9296ceb924341e61 Mon Sep 17 00:00:00 2001 From: computerlovebot Date: Thu, 15 Oct 2026 01:26:38 +0000 Subject: [PATCH 1/3] [verified] feat(desktop): add durable advanced backend settings --- desktop/README.md | 4 +- .../Sources/Umbod/AdvancedSettingsView.swift | 79 +++++ desktop/native/Sources/Umbod/UmbodApp.swift | 13 +- .../UmbodCore/AdministrationClient.swift | 2 + .../Sources/UmbodCore/AdvancedSettings.swift | 95 ++++++ .../UmbodTests/AdvancedSettingsTests.swift | 51 ++++ .../Tests/UmbodTests/ContractTests.swift | 18 ++ desktop/python/README.md | 5 +- desktop/python/tests/test_settings.py | 198 +++++++++++++ desktop/python/tests/test_settings_restart.py | 273 ++++++++++++++++++ desktop/python/umbod_desktop/configuration.py | 8 +- desktop/python/umbod_desktop/runtime.py | 25 ++ desktop/python/umbod_desktop/settings.py | 12 + desktop/python/umbod_desktop/storage.py | 18 +- 14 files changed, 791 insertions(+), 10 deletions(-) create mode 100644 desktop/native/Sources/Umbod/AdvancedSettingsView.swift create mode 100644 desktop/native/Sources/UmbodCore/AdvancedSettings.swift create mode 100644 desktop/native/Tests/UmbodTests/AdvancedSettingsTests.swift create mode 100644 desktop/python/tests/test_settings.py create mode 100644 desktop/python/tests/test_settings_restart.py create mode 100644 desktop/python/umbod_desktop/settings.py diff --git a/desktop/README.md b/desktop/README.md index 26ebeb88..a76d267d 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -43,6 +43,8 @@ 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_` and `import_` 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. + 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. @@ -52,7 +54,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/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/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). diff --git a/desktop/native/Sources/Umbod/AdvancedSettingsView.swift b/desktop/native/Sources/Umbod/AdvancedSettingsView.swift new file mode 100644 index 00000000..021ddb6c --- /dev/null +++ b/desktop/native/Sources/Umbod/AdvancedSettingsView.swift @@ -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(settings.saved == nil || settings.working || model.busy || (!settings.hasPendingChanges && 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)" } + } +} diff --git a/desktop/native/Sources/Umbod/UmbodApp.swift b/desktop/native/Sources/Umbod/UmbodApp.swift index 1e0a67cc..c63e8439 100644 --- a/desktop/native/Sources/Umbod/UmbodApp.swift +++ b/desktop/native/Sources/Umbod/UmbodApp.swift @@ -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] = [:] @@ -483,10 +484,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 { @@ -554,7 +555,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()) diff --git a/desktop/native/Sources/UmbodCore/AdministrationClient.swift b/desktop/native/Sources/UmbodCore/AdministrationClient.swift index adaf6739..c7872796 100644 --- a/desktop/native/Sources/UmbodCore/AdministrationClient.swift +++ b/desktop/native/Sources/UmbodCore/AdministrationClient.swift @@ -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() diff --git a/desktop/native/Sources/UmbodCore/AdvancedSettings.swift b/desktop/native/Sources/UmbodCore/AdvancedSettings.swift new file mode 100644 index 00000000..cab21696 --- /dev/null +++ b/desktop/native/Sources/UmbodCore/AdvancedSettings.swift @@ -0,0 +1,95 @@ +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 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)" + } + } +} diff --git a/desktop/native/Tests/UmbodTests/AdvancedSettingsTests.swift b/desktop/native/Tests/UmbodTests/AdvancedSettingsTests.swift new file mode 100644 index 00000000..0544fe5d --- /dev/null +++ b/desktop/native/Tests/UmbodTests/AdvancedSettingsTests.swift @@ -0,0 +1,51 @@ +import Foundation +import UmbodCore + +@MainActor func advancedSettingsTests() async throws { + let model = AdvancedSettingsModel() + let defaults = DesktopBackendSettings() + var pending = defaults + var running = defaults + var calls: [String] = [] + let request: ([String: Any]) async throws -> [String: Any] = { body in + let action = body["action"] as! String + calls.append(action) + if action == "settings_save" { pending = try DesktopBackendSettings(body["settings"] as! [String: Any]) } + if action == "health" { return ["status": "ok"] } + return ["pending": pending.payload, "effective": running.payload, "restart_required": pending != running] + } + try await model.load(request: request) + precondition(model.effective == defaults && !model.hasPendingChanges) + model.requestAdministrator(true) + precondition(model.confirmAdministrator && !model.draft.administratorEnabled) + model.cancelAdministrator() + precondition(!model.confirmAdministrator && !model.draft.administratorEnabled) + model.requestAdministrator(true) + model.acceptAdministrator() + model.draft.exposureMode = .flat + precondition(model.hasPendingChanges) + await model.saveAndRestart(request: request, stop: { + calls.append("stop") + precondition(model.saved == pending && model.effective == defaults) + }, start: { calls.append("start"); running = pending }) + precondition(calls.suffix(5) == ["settings_save", "stop", "start", "health", "settings_get"]) + precondition(model.effective == pending && !model.hasPendingChanges && model.error.isEmpty) + model.draft.exposureMode = .codemode + precondition(model.notice.isEmpty, "A new draft must not retain the previous active-settings notice") + await model.saveAndRestart(request: request, stop: {}, start: { throw ContractError.message("Fixture port conflict") }) + precondition(model.saved?.exposureMode == .codemode && model.effective == nil && model.hasPendingChanges) + precondition(model.error.contains("saved") && model.error.contains("Retry") && model.error.contains("Fixture port conflict")) + await model.saveAndRestart(request: request, stop: {}, start: { running = pending }) + precondition(model.error.isEmpty && model.effective?.exposureMode == .codemode) + model.draft.administratorEnabled = false + let previous = model.saved + await model.saveAndRestart(request: { _ in throw ContractError.message("Fixture disk full") }, stop: { preconditionFailure("Must not stop after failed save") }, start: {}) + precondition(model.saved == previous && model.effective == previous && model.hasPendingChanges) + precondition(model.error.contains("Could not verify") && model.error.contains("not restarted")) + await model.saveAndRestart(request: { body in + if body["action"] as? String == "health" { return ["status": "failed"] } + return try await request(body) + }, stop: {}, start: { running = pending }) + precondition(model.effective == nil && model.error.contains("Retry")) + ContractTests.log("PASS: advanced settings confirmation, pending, save/stop/start/health, failed-save and restart recovery") +} diff --git a/desktop/native/Tests/UmbodTests/ContractTests.swift b/desktop/native/Tests/UmbodTests/ContractTests.swift index 11aaf990..e958ea81 100644 --- a/desktop/native/Tests/UmbodTests/ContractTests.swift +++ b/desktop/native/Tests/UmbodTests/ContractTests.swift @@ -4,6 +4,7 @@ import UmbodCore static func log(_ value: String) { FileHandle.standardOutput.write(Data((value + "\n").utf8)) } @MainActor static func main() async throws { try updateTests() + try await advancedSettingsTests() try await connectorEditorTests() precondition(AdministrationClient.errorMessage(["detail": ["code": "auth_rejected", "message": "The downstream server rejected authentication."]], statusCode: 422) == "The downstream server rejected authentication. Sign in with browser from the connector page, or check its authentication credentials.") precondition(AdministrationClient.errorMessage(["detail": ["code": "unreachable", "message": "The downstream MCP server could not be reached."]], statusCode: 422) == "The downstream MCP server could not be reached.") @@ -157,6 +158,23 @@ import UmbodCore restoredAgent.standardOutput = FileHandle.nullDevice try restoredAgent.run(); restoredAgent.waitUntilExit() precondition(restoredAgent.terminationStatus == 0, "Persisted permissions and transport must survive restart") + let advanced = AdvancedSettingsModel() + try await advanced.load { try await secondUI.request($0) } + precondition(advanced.effective == DesktopBackendSettings()) + advanced.draft.exposureMode = .flat + advanced.requestAdministrator(true) + advanced.acceptAdministrator() + let beforeAdvancedRestart = secondUI.processID + await advanced.saveAndRestart(request: { try await secondUI.request($0) }, stop: { + try await secondUI.shutdown() + }, start: { + try await secondUI.start(executable: executable, directory: persistentDirectory, persistent: true, environment: environment, testCredentials: true) + }) + precondition(advanced.error.isEmpty && advanced.effective == advanced.draft && !advanced.hasPendingChanges) + precondition(secondUI.processID != beforeAdvancedRestart && secondUI.gateway == persistentEndpoint) + let afterAdvancedRestart = try await secondUI.request(["action": "status"]) + precondition((afterAdvancedRestart["allowed_tools"] as? [String])?.contains(toolName) == true) + log("PASS: native settings save/restart verifies health, effective values and preserved grants through the packaged backend") var retainedDraft = server retainedDraft.name = "Retained connector" let retainedEditor = ConnectorEditor(draft: retainedDraft) diff --git a/desktop/python/README.md b/desktop/python/README.md index b405e630..ccec75f7 100644 --- a/desktop/python/README.md +++ b/desktop/python/README.md @@ -5,12 +5,13 @@ The SwiftUI app keeps its native screens. `AdministrationClient.swift` maps the ## Entry points and ownership - `main.py`: packaged `serve` and `bridge` commands, loopback socket, stable port, process lock, readiness pipe, owner shutdown and dev/release isolation. Its `credential` command lets Swift reattach through a private pipe using the same installed backend identity; Swift does not access Keychain independently. Legacy positional bridge identities remain accepted. `build.sh` bundles a relocatable interpreter and locked dependencies, then signs a fresh bundle. -- `runtime.py`, `configuration.py`: shared application composition and authenticated `/desktop` routes. SQL event polling synchronizes API writes with MCP. Native single-tool and bulk enablement publish the connector before applying activation and batched permissions; rename-only saves preserve the live transport. Native removal revokes the selected connector’s desktop-owner grants, including stale tool grants, before deleting its registry entry and transport. Dedicated metrics listeners are disabled for desktop. MCP uses gateway exposure (`search_tools` and `execute_tool`) so clients discover connector schemas on demand instead of loading the complete catalogue during startup. -- `security.py`: separate random admin, agent, upstream and encryption credentials in macOS Keychain. No development auth or plaintext fallback. Existing agent token hashes are retained for migration; they never grant administration access. +- `runtime.py`, `configuration.py`: shared application composition and authenticated `/desktop` routes. SQL event polling synchronizes API writes with MCP. Native single-tool and bulk enablement publish the connector before applying activation and batched permissions; rename-only saves preserve the live transport. Native removal revokes the selected connector’s desktop-owner grants, including stale tool grants, before deleting its registry entry and transport. Dedicated metrics listeners are disabled for desktop. MCP defaults to gateway exposure (`search_tools` and `execute_tool`); saved backend settings select gateway, flat or codemode before constructing the application. Settings never hot-swap registered tools. +- `security.py`: separate random admin, agent, upstream and encryption credentials in macOS Keychain. No development auth or plaintext fallback. Existing agent token hashes are retained for migration; they never grant REST owner access. Agent identities receive `desktop-owner`, so opting into administrator MCP tools grants all of these clients MCP administration access. - `upstreams.py`, `models.py`: validated stdio and Streamable HTTP transports exposed through private authenticated loopback proxies to the shared downstream connector API. A proxy owns its lifespan in one task. Offline drafts remain editable and cannot retain an obsolete live transport after an invalid edit. Upstream HTTP challenges and runtime OAuth reauthentication persist a sign-in-required status; successful requests clear it. The private loopback bearer is consumed before proxying, so only connector-owned credentials reach upstream servers. - `oauth.py`: macOS Keychain storage and the `DesktopOAuthHost` adapter for shared OAuth connection storage, stable loopback callback addresses, and local transport reconnection. `callback_transport` forwards callback parameters to shared protocol validation. Discovery, public-client registration, scopes, status transitions, safe errors, diagnostics, cancellation, token exchange/refresh, and authorization-versus-discovery outcomes live in `umbod.core.connectors.downstream_mcp.oauth`. Native clients use the shared `/api/admin/connectors/mcp/oauth` API; private `oauth_*` host actions are no longer supported. Existing callback paths and Keychain records remain compatible. - `migration.py`, `legacy.py`: authenticated retirement of the old process, resumable settings import, OAuth credential conversion and stable endpoint/client identity preservation. Original settings and Keychain entries are preserved. No PID from disk is signalled. - `tool_names.py`: startup repair of desktop-generated UUID/hash tool prefixes through the shared metadata API. Readable prefixes use the shared core naming policy; duplicate imported/repaired names receive numeric suffixes. Only managed connections whose prefix matches their generated public path are repaired. Explicit prefixes, IDs, credentials, paths, activation and grants are preserved; rejected repairs keep the working prefix, log a warning, and retry on restart. New native connections omit the prefix so the shared Python backend derives it from the display name. Renaming a display label does not implicitly rename published tools. +- `settings.py`: strict exposure-mode and boolean administrator settings, defaulting to gateway/off for existing profiles. `GET /desktop/settings` returns `pending`, immutable process `effective`, and `restart_required`; `PUT /desktop/settings` validates and atomically saves pending values without changing the running app. Both require the private owner/admin bearer; the agent token cannot access this API even when administrator MCP is enabled. Swift verifies health and reads back effective settings after restarting. Unknown modes, malformed booleans and unexpected fields are rejected. Invalid persisted settings fail closed without rewriting or resetting the profile; repair only the `settings` object (or restore its valid backup), leaving connectors, credentials, migration journals and grants intact. Operator `.env` settings remain ignored. - `storage.py`, `usage.py`: owner-only atomic host metadata and aggregate call counts, without arguments or results. Shared connectors and encrypted configuration live in `backend.sqlite3`. - `client_setup.py`: consented configuration previews, conflict detection and byte-preserving backup restoration. - `testing.py`: explicitly selected fixture credentials, restricted to owned `umbod-test-*` directories under the system temporary directory. This is never a fallback for unavailable Keychain access; normal profiles always use Keychain. diff --git a/desktop/python/tests/test_settings.py b/desktop/python/tests/test_settings.py new file mode 100644 index 00000000..cabc3023 --- /dev/null +++ b/desktop/python/tests/test_settings.py @@ -0,0 +1,198 @@ +import asyncio +import json +import socket +from contextlib import asynccontextmanager + +import httpx +import pytest +import uvicorn +from test_runtime import MemoryVault + +from umbod_desktop.configuration import desktop_settings +from umbod_desktop.runtime import create_desktop_app +from umbod_desktop.storage import DesktopState + + +def test_existing_profile_defaults_ignore_operator_environment(tmp_path, monkeypatch): + original = { + "version": 2, + "servers": {}, + "migration": {"status": "complete"}, + "legacy_agent_hashes": ["retained"], + "client_setup": {"saved": True}, + } + path = tmp_path / "desktop-v2.json" + path.write_text(json.dumps(original)) + monkeypatch.setenv("UMBOD_MCP_TOOL_EXPOSURE", "flat") + monkeypatch.setenv("UMBOD_FEATURE_MCP_ADMINISTRATOR_ENABLED", "true") + config = desktop_settings(tmp_path, "http://127.0.0.1:8000", "test-root") + assert config.mcp.connector_tool_exposure_mode == "gateway" + assert config.feature_toggles.mcp_administrator_enabled is False + assert DesktopState(tmp_path).data == original + + +def test_saved_settings_drive_backend_composition(tmp_path): + original = { + "version": 2, + "servers": {"saved": {"id": "keep"}}, + "settings": { + "connector_tool_exposure_mode": "flat", + "mcp_administrator_enabled": True, + }, + "migration": {"status": "complete"}, + "legacy_agent_hashes": ["keep"], + } + (tmp_path / "desktop-v2.json").write_text(json.dumps(original)) + config = desktop_settings(tmp_path, "http://127.0.0.1:8000", "test-root") + assert config.mcp.connector_tool_exposure_mode == "flat" + assert config.feature_toggles.mcp_administrator_enabled is True + assert DesktopState(tmp_path).data == original + + +@pytest.mark.parametrize( + "settings", + [ + None, + [], + {"connector_tool_exposure_mode": "unknown"}, + {"mcp_administrator_enabled": 1}, + ], +) +def test_malformed_saved_settings_fail_closed_without_rewriting(tmp_path, settings): + path = tmp_path / "desktop-v2.json" + original = json.dumps({"version": 2, "servers": {"keep": {}}, "settings": settings}) + path.write_text(original) + with pytest.raises(ValueError, match="Desktop settings"): + DesktopState(tmp_path) + assert path.read_text() == original + + +@asynccontextmanager +async def running_backend(directory, vault): + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen(128) + origin = f"http://127.0.0.1:{listener.getsockname()[1]}" + app, token = await create_desktop_app( + directory, "dev", origin, vault, lambda: None + ) + server = uvicorn.Server(uvicorn.Config(app, log_level="error")) + task = asyncio.create_task(server.serve(sockets=[listener])) + try: + async with asyncio.timeout(30): + while not server.started: + if task.done(): + await task + await asyncio.sleep(0.02) + async with httpx.AsyncClient( + base_url=origin, headers={"Authorization": f"Bearer {token}"} + ) as client: + yield client, origin + finally: + server.should_exit = True + await task + + +async def test_settings_api_is_pending_authenticated_and_durable(tmp_path): + legacy = { + "version": 2, + "servers": {}, + "migration": {"status": "complete"}, + "diagnostics": {"enabled": True}, + "legacy_agent_hashes": [], + } + (tmp_path / "desktop-v2.json").write_text(json.dumps(legacy)) + vault = MemoryVault() + async with running_backend(tmp_path, vault) as (client, origin): + response = await client.get("/desktop/settings") + assert response.status_code == 200 + defaults = { + "connector_tool_exposure_mode": "gateway", + "mcp_administrator_enabled": False, + } + assert response.json() == { + "pending": defaults, + "effective": defaults, + "restart_required": False, + } + assert DesktopState(tmp_path).data["settings"] == defaults + for token in ("wrong", vault.get("python:agent-token")): + for method in ("GET", "PUT"): + denied = await client.request( + method, + "/desktop/settings", + headers={"Authorization": f"Bearer {token}"}, + json=defaults, + ) + assert denied.status_code == 401 + selected = { + "connector_tool_exposure_mode": "codemode", + "mcp_administrator_enabled": True, + } + saved = await client.put("/desktop/settings", json=selected) + assert saved.status_code == 200 + assert saved.json() == { + "pending": selected, + "effective": defaults, + "restart_required": True, + } + assert (await client.get("/desktop/settings")).json() == saved.json() + assert DesktopState(tmp_path).data["settings"] == selected + for invalid in ( + {**selected, "connector_tool_exposure_mode": "typo"}, + {**selected, "mcp_administrator_enabled": "false"}, + {**selected, "extra": True}, + None, + [], + ): + assert ( + await client.put("/desktop/settings", json=invalid) + ).status_code == 422 + assert (await client.get("/desktop/settings")).json() == saved.json() + credentials = dict(vault.values) + async with running_backend(tmp_path, vault) as (client, origin): + assert (await client.get("/desktop/settings")).json() == { + "pending": selected, + "effective": selected, + "restart_required": False, + } + assert (await client.get("/desktop/health")).json()["status"] == "ok" + assert vault.values == credentials + assert all( + DesktopState(tmp_path).data[key] == value for key, value in legacy.items() + ) + + +async def test_failed_settings_write_keeps_memory_and_disk_unchanged( + tmp_path, monkeypatch +): + vault = MemoryVault() + async with running_backend(tmp_path, vault) as (client, origin): + before = (await client.get("/desktop/settings")).json() + document = (tmp_path / "desktop-v2.json").read_bytes() + + def fail_write(path, value): + raise OSError("Fixture disk full") + + with monkeypatch.context() as context: + context.setattr("umbod_desktop.runtime.atomic_json", fail_write) + failed = await client.put( + "/desktop/settings", + json={ + "connector_tool_exposure_mode": "flat", + "mcp_administrator_enabled": True, + }, + ) + assert failed.status_code == 500 + assert (await client.get("/desktop/settings")).json() == before + assert (tmp_path / "desktop-v2.json").read_bytes() == document + # The owner can retry without rebuilding the profile. + assert ( + await client.put( + "/desktop/settings", + json={ + "connector_tool_exposure_mode": "flat", + "mcp_administrator_enabled": True, + }, + ) + ).status_code == 200 diff --git a/desktop/python/tests/test_settings_restart.py b/desktop/python/tests/test_settings_restart.py new file mode 100644 index 00000000..220070d3 --- /dev/null +++ b/desktop/python/tests/test_settings_restart.py @@ -0,0 +1,273 @@ +"""Exercise saved settings through fresh host processes, real MCP and durable SQL.""" + +import asyncio +import json +import socket +import sys +import tempfile +from contextlib import asynccontextmanager +from pathlib import Path + +import httpx +from fastmcp import Client +from fastmcp.client.transports import StreamableHttpTransport + +from umbod_desktop.security import DESKTOP_GROUP + + +@asynccontextmanager +async def process_backend(directory): + process = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "umbod_desktop.main", + "serve", + "--profile", + "dev", + "--data-dir", + str(directory), + "--test-credentials", + "--persistent", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + try: + ready = json.loads(await asyncio.wait_for(process.stdout.readline(), 45)) + assert "error" not in ready, ready + origin = "http://" + ready["management"] + async with httpx.AsyncClient( + base_url=origin, + headers={"Authorization": "Bearer " + ready["admin_token"]}, + timeout=30, + ) as client: + assert (await client.get("/desktop/health")).json()["status"] == "ok" + yield client, origin, ready["pid"] + assert (await client.post("/desktop/shutdown")).status_code == 200 + assert await asyncio.wait_for(process.wait(), 30) == 0 + finally: + if process.returncode is None: + process.terminate() + await asyncio.wait_for(process.wait(), 15) + + +async def test_modes_and_administration_survive_real_process_restarts(): + with tempfile.TemporaryDirectory(prefix="umbod-test-settings-") as temporary: + directory = Path(temporary) + fixture = directory / "fixture.py" + fixture.write_text( + "from fastmcp import FastMCP\nm=FastMCP('fixture')\n@m.tool\ndef echo(message: str) -> str: return message\nm.run(show_banner=False)\n" + ) + async with process_backend(directory) as (client, origin, pid): + prepared = await client.post( + "/desktop/upstreams", + json={ + "name": "Saved", + "transport": "stdio", + "command": sys.executable, + "args": [str(fixture)], + }, + ) + assert prepared.status_code == 200, prepared.text + created = await client.post( + "/api/admin/connectors/mcp", + json={ + "metadata": { + "display_name": "Saved", + "capability_description": "Fixture tools", + "public_path": "/mcp/proxies/saved", + }, + "configuration": prepared.json()["configuration"], + }, + ) + assert created.status_code == 201, created.text + identifier = created.json()["connector_id"] + assert ( + await client.put( + f"/desktop/upstreams/{prepared.json()['host_id']}/binding", + json={"connector_id": identifier}, + ) + ).status_code == 200 + base = f"/api/admin/connectors/mcp/{identifier}" + assert (await client.post(base + "/discoveries")).status_code == 200 + assert (await client.put(base + "/publication")).status_code == 200 + assert ( + await client.put( + base + "/tools/activation", + json={ + "tools": [ + { + "tool_id": "echo", + "activation_status": "enabled", + "invocation_mode": "direct", + "expected_policy_revision": 0, + } + ] + }, + ) + ).status_code == 200 + assert ( + await client.post(f"/api/admin/mcp-permissions/groups/{DESKTOP_GROUP}") + ).status_code in (200, 201) + permissions = ( + f"/api/admin/mcp-permissions/groups/{DESKTOP_GROUP}/permissions" + ) + assert ( + await client.put( + permissions, + json={ + "capabilities": [ + { + "connector_id": identifier, + "capability_kind": "tool", + "capability_key": "echo", + "permission_status": "enabled", + } + ] + }, + ) + ).status_code == 200 + # Credential payloads never enter host settings and must not be rotated. + assert ( + await client.put( + "/desktop/credentials/upstream:preserved", + json={"value": "fixture-secret"}, + ) + ).status_code == 200 + baseline = (await client.get(base)).json() + grants = (await client.get(permissions)).json() + first = { + "connector_tool_exposure_mode": "flat", + "mcp_administrator_enabled": False, + } + assert (await client.put("/desktop/settings", json=first)).json()[ + "restart_required" + ] + credentials = (directory / "fixture-credentials.json").read_bytes() + state_before_failure = (directory / "desktop-v2.json").read_bytes() + # Force a real failed startup on the saved port, then recover without + # resetting any profile state or changing the selected settings. + port = json.loads((directory / "python-endpoint.json").read_text())["port"] + with socket.socket() as conflict: + conflict.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + conflict.bind(("127.0.0.1", port)) + conflict.listen(1) + failed = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "umbod_desktop.main", + "serve", + "--profile", + "dev", + "--data-dir", + str(directory), + "--test-credentials", + "--persistent", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + output, _ = await asyncio.wait_for(failed.communicate(), 45) + assert failed.returncode == 1 + assert "error" in json.loads(output) + assert (directory / "desktop-v2.json").read_bytes() == state_before_failure + assert (directory / "fixture-credentials.json").read_bytes() == credentials + previous_pid = pid + cases = [ + ("flat", False), + ("gateway", True), + ("codemode", True), + ("gateway", False), + ] + for index, (mode, admin) in enumerate(cases): + async with process_backend(directory) as (client, origin, pid): + assert pid != previous_pid + previous_pid = pid + selected = { + "connector_tool_exposure_mode": mode, + "mcp_administrator_enabled": admin, + } + assert (await client.get("/desktop/settings")).json() == { + "pending": selected, + "effective": selected, + "restart_required": False, + } + restored = (await client.get(base)).json() + assert {k: v for k, v in restored.items() if k != "health"} == { + k: v for k, v in baseline.items() if k != "health" + } + assert (await client.get(permissions)).json() == grants + token = json.loads(credentials)["python:agent-token"] + for method in ("GET", "PUT"): + denied = await client.request( + method, + "/desktop/settings", + headers={"Authorization": "Bearer " + token}, + json=selected, + ) + assert denied.status_code == 401 + async with Client( + StreamableHttpTransport( + origin + "/mcp", headers={"Authorization": "Bearer " + token} + ) + ) as agent: + expected = ( + {"Saved_echo"} + if mode == "flat" + else { + "search_tools", + "execute_tool" if mode == "gateway" else "execute_code", + } + ) + if admin: + expected |= { + "read_connector_configuration", + "upsert_connector_configuration", + } + async with asyncio.timeout(20): + while { + tool.name for tool in await agent.list_tools() + } != expected: + await asyncio.sleep(0.1) + if mode == "flat": + assert "persisted" in str( + await agent.call_tool( + "Saved_echo", {"message": "persisted"} + ) + ) + elif mode == "gateway": + assert "persisted" in str( + await agent.call_tool( + "execute_tool", + { + "tool_name": "Saved_echo", + "arguments": {"message": "persisted"}, + }, + ) + ) + else: + result = await agent.call_tool( + "search_tools", {"query": "echo"} + ) + assert "Saved_echo" in str(result) + if admin: + # Successful invocation proves desktop-owner authorization, + # not merely registration of the administrator names. + result = await agent.call_tool( + "read_connector_configuration", + { + "connector_kind": "downstream_mcp", + "connector_id": identifier, + }, + ) + assert not result.is_error + if index + 1 < len(cases): + next_mode, next_admin = cases[index + 1] + saved = await client.put( + "/desktop/settings", + json={ + "connector_tool_exposure_mode": next_mode, + "mcp_administrator_enabled": next_admin, + }, + ) + assert saved.json()["effective"] == selected + assert saved.json()["restart_required"] + assert (directory / "fixture-credentials.json").read_bytes() == credentials diff --git a/desktop/python/umbod_desktop/configuration.py b/desktop/python/umbod_desktop/configuration.py index 4e968a35..88f0e075 100644 --- a/desktop/python/umbod_desktop/configuration.py +++ b/desktop/python/umbod_desktop/configuration.py @@ -6,9 +6,11 @@ from umbod.config.secret_derivation import derive_secret from .security import DESKTOP_GROUP +from .storage import DesktopState def desktop_settings(directory: Path, origin: str, root_secret: str) -> AppConfig: + advanced = DesktopState(directory).settings return AppConfig.model_validate( { "runtime": { @@ -28,7 +30,7 @@ def desktop_settings(directory: Path, origin: str, root_secret: str) -> AppConfi "auth_mode": "none", "messaging_transport": "sql", "permission_group_claim": "groups", - "connector_tool_exposure_mode": "gateway", + "connector_tool_exposure_mode": advanced.connector_tool_exposure_mode, "downstream_oauth_enabled": True, }, "admin_authentication": { @@ -54,7 +56,9 @@ def desktop_settings(directory: Path, origin: str, root_secret: str) -> AppConfi "directory": str(directory / "oauth"), "encryption_key": derive_secret(root_secret, "oauth-token-storage"), }, - "feature_toggles": {"mcp_administrator_enabled": False}, + "feature_toggles": { + "mcp_administrator_enabled": advanced.mcp_administrator_enabled + }, "cors": {"origins": []}, } ) diff --git a/desktop/python/umbod_desktop/runtime.py b/desktop/python/umbod_desktop/runtime.py index bd70d1a8..ccf8d27c 100644 --- a/desktop/python/umbod_desktop/runtime.py +++ b/desktop/python/umbod_desktop/runtime.py @@ -30,6 +30,7 @@ LoopbackAuthentication, persistent_secret, ) +from .settings import DesktopSettings from .storage import DesktopState, atomic_json from .upstreams import DesktopUpstreams from .usage import DesktopUsageSink @@ -56,6 +57,7 @@ async def create_desktop_app( if state.data.get("profile", profile) != profile: raise ValueError("Desktop profile does not match its data directory") state.data["profile"] = profile + state.data.setdefault("settings", state.settings.model_dump()) if "legacy_agent_hashes" not in state.data: state.data["legacy_agent_hashes"] = saved_agent_hashes(directory) state.save() @@ -112,6 +114,29 @@ async def health(): else "development", } + effective_settings = DesktopSettings( + connector_tool_exposure_mode=settings.mcp.connector_tool_exposure_mode, + mcp_administrator_enabled=settings.feature_toggles.mcp_administrator_enabled, + ) + + @host.get("/desktop/settings") + async def read_settings(): + pending = state.settings + return { + "pending": pending.model_dump(), + "effective": effective_settings.model_dump(), + "restart_required": pending != effective_settings, + } + + @host.put("/desktop/settings") + async def save_settings(pending: DesktopSettings): + # Persist before publishing the new pending value. Failed writes leave both + # the running configuration and in-memory metadata untouched. + updated = {**state.data, "settings": pending.model_dump()} + atomic_json(state.path, updated) + state.data = updated + return await read_settings() + @host.post("/desktop/shutdown") async def stop(): shutdown() diff --git a/desktop/python/umbod_desktop/settings.py b/desktop/python/umbod_desktop/settings.py new file mode 100644 index 00000000..a59ea865 --- /dev/null +++ b/desktop/python/umbod_desktop/settings.py @@ -0,0 +1,12 @@ +"""Validated, backend-owned settings. Changes apply only at process composition.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, StrictBool + + +class DesktopSettings(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + connector_tool_exposure_mode: Literal["gateway", "flat", "codemode"] = "gateway" + mcp_administrator_enabled: StrictBool = False diff --git a/desktop/python/umbod_desktop/storage.py b/desktop/python/umbod_desktop/storage.py index c51b84fc..fdc4e527 100644 --- a/desktop/python/umbod_desktop/storage.py +++ b/desktop/python/umbod_desktop/storage.py @@ -5,6 +5,10 @@ import tempfile from pathlib import Path +from pydantic import ValidationError + +from .settings import DesktopSettings + def atomic_json(path: Path, value: object) -> None: atomic_bytes(path, json.dumps(value).encode()) @@ -31,8 +35,20 @@ def __init__(self, directory: Path) -> None: if self.path.exists() else {"version": 2, "servers": {}} ) - if self.data.get("version") != 2: + if not isinstance(self.data, dict) or self.data.get("version") != 2: raise ValueError("Unsupported desktop state version") + try: + self.settings + except ValidationError: + raise ValueError( + "Desktop settings in desktop-v2.json are invalid. Restore valid settings " + "(gateway, flat or codemode and a boolean administrator switch), then restart. " + "Your profile has not been reset." + ) from None + + @property + def settings(self) -> DesktopSettings: + return DesktopSettings.model_validate(self.data.get("settings", {})) def save(self) -> None: atomic_json(self.path, self.data) From c5badcb458ddb25903d63fd24f4d94d81936b95e Mon Sep 17 00:00:00 2001 From: Jonas Kyhse-Andersen Date: Mon, 19 Oct 2026 20:50:23 +0200 Subject: [PATCH 2/3] fix(desktop): preserve main actor isolation in settings test request --- desktop/native/Tests/UmbodTests/AdvancedSettingsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/native/Tests/UmbodTests/AdvancedSettingsTests.swift b/desktop/native/Tests/UmbodTests/AdvancedSettingsTests.swift index 0544fe5d..50c7ccd1 100644 --- a/desktop/native/Tests/UmbodTests/AdvancedSettingsTests.swift +++ b/desktop/native/Tests/UmbodTests/AdvancedSettingsTests.swift @@ -7,7 +7,7 @@ import UmbodCore var pending = defaults var running = defaults var calls: [String] = [] - let request: ([String: Any]) async throws -> [String: Any] = { body in + let request: @MainActor ([String: Any]) async throws -> [String: Any] = { body in let action = body["action"] as! String calls.append(action) if action == "settings_save" { pending = try DesktopBackendSettings(body["settings"] as! [String: Any]) } From 12be341563b705de36e46c9650a4e6d0b8ef7fa8 Mon Sep 17 00:00:00 2001 From: Jonas Kyhse-Andersen Date: Mon, 19 Oct 2026 20:52:46 +0200 Subject: [PATCH 3/3] fix(desktop): gate offline settings restart to saved recovery --- desktop/README.md | 4 ++++ desktop/native/Sources/Umbod/AdvancedSettingsView.swift | 2 +- desktop/native/Sources/UmbodCore/AdvancedSettings.swift | 4 ++++ .../native/Tests/UmbodTests/AdvancedSettingsTests.swift | 8 ++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/desktop/README.md b/desktop/README.md index a069840b..2f2a8078 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -45,6 +45,10 @@ Public tools use a readable connector prefix derived by Python, for example `Din 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. diff --git a/desktop/native/Sources/Umbod/AdvancedSettingsView.swift b/desktop/native/Sources/Umbod/AdvancedSettingsView.swift index 021ddb6c..34bad410 100644 --- a/desktop/native/Sources/Umbod/AdvancedSettingsView.swift +++ b/desktop/native/Sources/Umbod/AdvancedSettingsView.swift @@ -51,7 +51,7 @@ struct AdvancedSettingsView: View { model.online = model.backend.isRunning if settings.error.isEmpty { try? await model.refresh() } } - }.disabled(settings.saved == nil || settings.working || model.busy || (!settings.hasPendingChanges && model.online)) + }.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) diff --git a/desktop/native/Sources/UmbodCore/AdvancedSettings.swift b/desktop/native/Sources/UmbodCore/AdvancedSettings.swift index cab21696..88a01756 100644 --- a/desktop/native/Sources/UmbodCore/AdvancedSettings.swift +++ b/desktop/native/Sources/UmbodCore/AdvancedSettings.swift @@ -41,6 +41,10 @@ public struct DesktopBackendSettings: Codable, Equatable, Sendable { @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 } diff --git a/desktop/native/Tests/UmbodTests/AdvancedSettingsTests.swift b/desktop/native/Tests/UmbodTests/AdvancedSettingsTests.swift index 50c7ccd1..c706296f 100644 --- a/desktop/native/Tests/UmbodTests/AdvancedSettingsTests.swift +++ b/desktop/native/Tests/UmbodTests/AdvancedSettingsTests.swift @@ -3,6 +3,7 @@ import UmbodCore @MainActor func advancedSettingsTests() async throws { let model = AdvancedSettingsModel() + precondition(!model.canSaveAndRestart(isOnline: true) && !model.canSaveAndRestart(isOnline: false)) let defaults = DesktopBackendSettings() var pending = defaults var running = defaults @@ -16,6 +17,7 @@ import UmbodCore } try await model.load(request: request) precondition(model.effective == defaults && !model.hasPendingChanges) + precondition(!model.canSaveAndRestart(isOnline: true) && !model.canSaveAndRestart(isOnline: false)) model.requestAdministrator(true) precondition(model.confirmAdministrator && !model.draft.administratorEnabled) model.cancelAdministrator() @@ -24,8 +26,10 @@ import UmbodCore model.acceptAdministrator() model.draft.exposureMode = .flat precondition(model.hasPendingChanges) + precondition(model.canSaveAndRestart(isOnline: true) && !model.canSaveAndRestart(isOnline: false)) await model.saveAndRestart(request: request, stop: { calls.append("stop") + precondition(!model.canSaveAndRestart(isOnline: true) && !model.canSaveAndRestart(isOnline: false)) precondition(model.saved == pending && model.effective == defaults) }, start: { calls.append("start"); running = pending }) precondition(calls.suffix(5) == ["settings_save", "stop", "start", "health", "settings_get"]) @@ -34,6 +38,10 @@ import UmbodCore precondition(model.notice.isEmpty, "A new draft must not retain the previous active-settings notice") await model.saveAndRestart(request: request, stop: {}, start: { throw ContractError.message("Fixture port conflict") }) precondition(model.saved?.exposureMode == .codemode && model.effective == nil && model.hasPendingChanges) + precondition(model.canSaveAndRestart(isOnline: false)) + model.draft.exposureMode = .flat + precondition(!model.canSaveAndRestart(isOnline: false)) + model.draft.exposureMode = .codemode precondition(model.error.contains("saved") && model.error.contains("Retry") && model.error.contains("Fixture port conflict")) await model.saveAndRestart(request: request, stop: {}, start: { running = pending }) precondition(model.error.isEmpty && model.effective?.exposureMode == .codemode)