diff --git a/CHANGELOG.md b/CHANGELOG.md index 243a379e2b7..4bad6ba3d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147. - CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman. - Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao. +- macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#34614) ### Fixes diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index 57d8ae87878..5e8238ebe92 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -9,6 +9,7 @@ import SwiftUI final class AppState { private let isPreview: Bool private var isInitializing = true + private var isApplyingRemoteTokenConfig = false private var configWatcher: ConfigFileWatcher? private var suppressVoiceWakeGlobalSync = false private var voiceWakeGlobalSyncTask: Task? @@ -214,9 +215,17 @@ final class AppState { } var remoteToken: String { - didSet { self.syncGatewayConfigIfNeeded() } + didSet { + guard !self.isApplyingRemoteTokenConfig else { return } + self.remoteTokenDirty = true + self.remoteTokenUnsupported = false + self.syncGatewayConfigIfNeeded() + } } + private(set) var remoteTokenDirty = false + private(set) var remoteTokenUnsupported = false + var remoteIdentity: String { didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } } } @@ -285,6 +294,7 @@ final class AppState { let configRoot = OpenClawConfigFile.loadDict() let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot) + let configRemoteToken = GatewayRemoteConfig.resolveTokenValue(root: configRoot) let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot) let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode self.remoteTransport = configRemoteTransport @@ -301,7 +311,9 @@ final class AppState { self.remoteTarget = storedRemoteTarget } self.remoteUrl = configRemoteUrl ?? "" - self.remoteToken = GatewayRemoteConfig.resolveTokenString(root: configRoot) ?? "" + self.remoteToken = configRemoteToken.textFieldValue + self.remoteTokenDirty = false + self.remoteTokenUnsupported = configRemoteToken.isUnsupportedNonString self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" @@ -379,6 +391,20 @@ final class AppState { return false } + private func applyRemoteTokenState(_ tokenValue: GatewayRemoteConfig.TokenValue) { + let nextToken = tokenValue.textFieldValue + let unsupported = tokenValue.isUnsupportedNonString + guard self.remoteToken != nextToken || self.remoteTokenDirty || self.remoteTokenUnsupported != unsupported + else { + return + } + self.isApplyingRemoteTokenConfig = true + self.remoteToken = nextToken + self.isApplyingRemoteTokenConfig = false + self.remoteTokenDirty = false + self.remoteTokenUnsupported = unsupported + } + private static func updatedRemoteGatewayConfig( current: [String: Any], transport: RemoteTransport, @@ -386,7 +412,8 @@ final class AppState { remoteHost: String?, remoteTarget: String, remoteIdentity: String, - remoteToken: String) -> (remote: [String: Any], changed: Bool) + remoteToken: String, + remoteTokenDirty: Bool) -> (remote: [String: Any], changed: Bool) { var remote = current var changed = false @@ -423,7 +450,9 @@ final class AppState { changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed } - changed = Self.updateGatewayString(&remote, key: "token", value: remoteToken) || changed + if remoteTokenDirty { + changed = Self.updateGatewayString(&remote, key: "token", value: remoteToken) || changed + } return (remote, changed) } @@ -447,7 +476,7 @@ final class AppState { let gateway = root["gateway"] as? [String: Any] let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root) - let remoteToken = GatewayRemoteConfig.resolveTokenString(root: root) ?? "" + let remoteToken = GatewayRemoteConfig.resolveTokenValue(root: root) let hasRemoteUrl = !(remoteUrl? .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty ?? true) @@ -479,9 +508,7 @@ final class AppState { if remoteUrlText != self.remoteUrl { self.remoteUrl = remoteUrlText } - if remoteToken != self.remoteToken { - self.remoteToken = remoteToken - } + self.applyRemoteTokenState(remoteToken) let targetMode = desiredMode ?? self.connectionMode if targetMode == .remote, @@ -515,7 +542,8 @@ final class AppState { remoteTarget: String, remoteIdentity: String, remoteUrl: String, - remoteToken: String) -> (root: [String: Any], changed: Bool) + remoteToken: String, + remoteTokenDirty: Bool) -> (root: [String: Any], changed: Bool) { var root = currentRoot var gateway = root["gateway"] as? [String: Any] ?? [:] @@ -551,7 +579,8 @@ final class AppState { remoteHost: remoteHost, remoteTarget: remoteTarget, remoteIdentity: remoteIdentity, - remoteToken: remoteToken) + remoteToken: remoteToken, + remoteTokenDirty: remoteTokenDirty) if updated.changed { gateway["remote"] = updated.remote changed = true @@ -577,6 +606,7 @@ final class AppState { let remoteTransport = self.remoteTransport let remoteUrl = self.remoteUrl let remoteToken = self.remoteToken + let remoteTokenDirty = self.remoteTokenDirty Task { @MainActor in // Keep app-only connection settings local to avoid overwriting remote gateway config. @@ -587,7 +617,8 @@ final class AppState { remoteTarget: remoteTarget, remoteIdentity: remoteIdentity, remoteUrl: remoteUrl, - remoteToken: remoteToken) + remoteToken: remoteToken, + remoteTokenDirty: remoteTokenDirty) guard synced.changed else { return } OpenClawConfigFile.saveDict(synced.root) } @@ -750,7 +781,8 @@ extension AppState { remoteHost: String?, remoteTarget: String, remoteIdentity: String, - remoteToken: String) -> [String: Any] + remoteToken: String, + remoteTokenDirty: Bool) -> [String: Any] { Self.updatedRemoteGatewayConfig( current: current, @@ -759,7 +791,8 @@ extension AppState { remoteHost: remoteHost, remoteTarget: remoteTarget, remoteIdentity: remoteIdentity, - remoteToken: remoteToken).remote + remoteToken: remoteToken, + remoteTokenDirty: remoteTokenDirty).remote } static func _testSyncedGatewayRoot( @@ -769,7 +802,8 @@ extension AppState { remoteTarget: String, remoteIdentity: String, remoteUrl: String, - remoteToken: String) -> [String: Any] + remoteToken: String, + remoteTokenDirty: Bool) -> [String: Any] { Self.syncedGatewayRoot( currentRoot: currentRoot, @@ -778,7 +812,8 @@ extension AppState { remoteTarget: remoteTarget, remoteIdentity: remoteIdentity, remoteUrl: remoteUrl, - remoteToken: remoteToken).root + remoteToken: remoteToken, + remoteTokenDirty: remoteTokenDirty).root } } #endif diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift index 86fa9828baf..2d923a5ea9e 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift @@ -188,13 +188,7 @@ actor GatewayEndpointStore { private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? { if isRemote { - if let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let token = remote["token"] as? String - { - return token.trimmingCharacters(in: .whitespacesAndNewlines) - } - return nil + return GatewayRemoteConfig.resolveTokenString(root: root) } if let gateway = root["gateway"] as? [String: Any], diff --git a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift index 2a9120d8e83..4eee8165d52 100644 --- a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift +++ b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift @@ -2,6 +2,28 @@ import Foundation import OpenClawKit enum GatewayRemoteConfig { + enum TokenValue: Equatable { + case missing + case plaintext(String) + case unsupportedNonString + + var textFieldValue: String { + switch self { + case let .plaintext(token): + token + case .missing, .unsupportedNonString: + "" + } + } + + var isUnsupportedNonString: Bool { + if case .unsupportedNonString = self { + return true + } + return false + } + } + static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport { guard let gateway = root["gateway"] as? [String: Any], let remote = gateway["remote"] as? [String: Any], @@ -24,15 +46,27 @@ enum GatewayRemoteConfig { return trimmed.isEmpty ? nil : trimmed } - static func resolveTokenString(root: [String: Any]) -> String? { + static func resolveTokenValue(root: [String: Any]) -> TokenValue { guard let gateway = root["gateway"] as? [String: Any], let remote = gateway["remote"] as? [String: Any], - let tokenRaw = remote["token"] as? String + let tokenRaw = remote["token"] else { - return nil + return .missing + } + guard let tokenString = tokenRaw as? String else { + return .unsupportedNonString + } + let trimmed = tokenString.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? .missing : .plaintext(trimmed) + } + + static func resolveTokenString(root: [String: Any]) -> String? { + switch self.resolveTokenValue(root: root) { + case let .plaintext(token): + token + case .missing, .unsupportedNonString: + nil } - let trimmed = tokenRaw.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed } static func resolveGatewayUrl(root: [String: Any]) -> URL? { diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index 5c2cae2659d..b55ed439489 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -298,7 +298,7 @@ struct GeneralSettings: View { Text("Gateway token") .font(.callout.weight(.semibold)) .frame(width: self.remoteLabelWidth, alignment: .leading) - SecureField("remote gateway auth token (gateway.auth.token)", text: self.$state.remoteToken) + SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken) .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) } @@ -306,6 +306,13 @@ struct GeneralSettings: View { .font(.caption) .foregroundStyle(.secondary) .padding(.leading, self.remoteLabelWidth + 10) + if self.state.remoteTokenUnsupported { + Text( + "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.") + .font(.caption) + .foregroundStyle(.orange) + .padding(.leading, self.remoteLabelWidth + 10) + } } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 578d3a6ff05..8f4d16420bc 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -203,10 +203,21 @@ extension OnboardingView { Text("Gateway token") .font(.callout.weight(.semibold)) .frame(width: labelWidth, alignment: .leading) - SecureField("remote gateway auth token (gateway.auth.token)", text: self.$state.remoteToken) + SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken) .textFieldStyle(.roundedBorder) .frame(width: fieldWidth) } + if self.state.remoteTokenUnsupported { + GridRow { + Text("") + .frame(width: labelWidth, alignment: .leading) + Text( + "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.") + .font(.caption) + .foregroundStyle(.orange) + .frame(width: fieldWidth, alignment: .leading) + } + } if self.state.remoteTransport == .direct { GridRow { Text("Gateway URL") diff --git a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift index 8dc47ffa1ad..172dc7ffc55 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift @@ -13,7 +13,8 @@ struct AppStateRemoteConfigTests { remoteHost: "gateway.example", remoteTarget: "alice@gateway.example", remoteIdentity: "/tmp/id_ed25519", - remoteToken: " secret-token ") + remoteToken: " secret-token ", + remoteTokenDirty: true) #expect(remote["token"] as? String == "secret-token") } @@ -27,49 +28,101 @@ struct AppStateRemoteConfigTests { remoteHost: nil, remoteTarget: "", remoteIdentity: "", - remoteToken: " ") + remoteToken: " ", + remoteTokenDirty: true) #expect((remote["token"] as? String) == nil) } @Test - func syncedGatewayRootPreservesTokenAcrossModeToggleAndClearsOnBlankRemoteToken() { - let remoteRoot = AppState._testSyncedGatewayRoot( - currentRoot: [:], + func syncedGatewayRootPreservesObjectTokenAcrossModeAndTransportChangesWhenUntouched() { + let initialRoot: [String: Any] = [ + "gateway": [ + "mode": "remote", + "remote": [ + "transport": "direct", + "url": "wss://old-gateway.example", + "token": [ + "$secretRef": "gateway-token", + ], + ], + ], + ] + + let sshRoot = AppState._testSyncedGatewayRoot( + currentRoot: initialRoot, connectionMode: .remote, - remoteTransport: .direct, - remoteTarget: "", + remoteTransport: .ssh, + remoteTarget: "alice@gateway.example", remoteIdentity: "", - remoteUrl: "wss://gateway.example", - remoteToken: " persisted-token ") - let remoteGateway = remoteRoot["gateway"] as? [String: Any] - let remoteConfig = remoteGateway?["remote"] as? [String: Any] - #expect(remoteGateway?["mode"] as? String == "remote") - #expect(remoteConfig?["token"] as? String == "persisted-token") + remoteUrl: "", + remoteToken: "", + remoteTokenDirty: false) + let sshRemote = (sshRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any] + #expect((sshRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") let localRoot = AppState._testSyncedGatewayRoot( - currentRoot: remoteRoot, + currentRoot: sshRoot, connectionMode: .local, - remoteTransport: .direct, + remoteTransport: .ssh, remoteTarget: "", remoteIdentity: "", remoteUrl: "", - remoteToken: "") + remoteToken: "", + remoteTokenDirty: false) let localGateway = localRoot["gateway"] as? [String: Any] - let localRemoteConfig = localGateway?["remote"] as? [String: Any] - // Local mode should not discard remote token state; users can return to remote mode later. + let localRemote = localGateway?["remote"] as? [String: Any] #expect(localGateway?["mode"] as? String == "local") - #expect(localRemoteConfig?["token"] as? String == "persisted-token") + #expect((localRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") + } - let clearedRoot = AppState._testSyncedGatewayRoot( - currentRoot: localRoot, - connectionMode: .remote, - remoteTransport: .direct, + @Test + func updatedRemoteGatewayConfigReplacesObjectTokenWhenUserEntersPlaintext() { + let remote = AppState._testUpdatedRemoteGatewayConfig( + current: [ + "token": [ + "$secretRef": "gateway-token", + ], + ], + transport: .direct, + remoteUrl: "wss://gateway.example", + remoteHost: nil, remoteTarget: "", remoteIdentity: "", + remoteToken: " fresh-token ", + remoteTokenDirty: true) + + #expect(remote["token"] as? String == "fresh-token") + } + + @Test + func updatedRemoteGatewayConfigClearsObjectTokenOnlyAfterExplicitEdit() { + let current: [String: Any] = [ + "token": [ + "$secretRef": "gateway-token", + ], + ] + + let preserved = AppState._testUpdatedRemoteGatewayConfig( + current: current, + transport: .direct, remoteUrl: "wss://gateway.example", - remoteToken: " ") - let clearedRemote = (clearedRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any] - #expect((clearedRemote?["token"] as? String) == nil) + remoteHost: nil, + remoteTarget: "", + remoteIdentity: "", + remoteToken: "", + remoteTokenDirty: false) + #expect((preserved["token"] as? [String: String])?["$secretRef"] == "gateway-token") + + let cleared = AppState._testUpdatedRemoteGatewayConfig( + current: current, + transport: .direct, + remoteUrl: "wss://gateway.example", + remoteHost: nil, + remoteTarget: "", + remoteIdentity: "", + remoteToken: " ", + remoteTokenDirty: true) + #expect((cleared["token"] as? String) == nil) } }