fix(macos): preserve unsupported remote gateway tokens

This commit is contained in:
Nimrod Gutman
2026-03-08 20:30:43 +02:00
committed by Nimrod Gutman
parent 3b7a72bffb
commit 3d3e8fe78c
7 changed files with 190 additions and 55 deletions

View File

@@ -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

View File

@@ -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<Void, Never>?
@@ -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

View File

@@ -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],

View File

@@ -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? {

View File

@@ -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)
}
}
}

View File

@@ -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")

View File

@@ -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)
}
}