diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index ef4917e7768..e670235ddba 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -213,6 +213,10 @@ final class AppState { didSet { self.syncGatewayConfigIfNeeded() } } + var remoteToken: String { + didSet { self.syncGatewayConfigIfNeeded() } + } + var remoteIdentity: String { didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } } } @@ -297,6 +301,7 @@ final class AppState { self.remoteTarget = storedRemoteTarget } self.remoteUrl = configRemoteUrl ?? "" + self.remoteToken = GatewayRemoteConfig.resolveTokenString(root: configRoot) ?? "" self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" @@ -380,7 +385,8 @@ final class AppState { remoteUrl: String, remoteHost: String?, remoteTarget: String, - remoteIdentity: String) -> (remote: [String: Any], changed: Bool) + remoteIdentity: String, + remoteToken: String) -> (remote: [String: Any], changed: Bool) { var remote = current var changed = false @@ -417,6 +423,8 @@ final class AppState { changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed } + changed = Self.updateGatewayString(&remote, key: "token", value: remoteToken) || changed + return (remote, changed) } @@ -439,6 +447,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 hasRemoteUrl = !(remoteUrl? .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty ?? true) @@ -470,6 +479,9 @@ final class AppState { if remoteUrlText != self.remoteUrl { self.remoteUrl = remoteUrlText } + if remoteToken != self.remoteToken { + self.remoteToken = remoteToken + } let targetMode = desiredMode ?? self.connectionMode if targetMode == .remote, @@ -504,6 +516,7 @@ final class AppState { let remoteIdentity = self.remoteIdentity let remoteTransport = self.remoteTransport let remoteUrl = self.remoteUrl + let remoteToken = self.remoteToken let desiredMode: String? = switch connectionMode { case .local: "local" @@ -541,7 +554,8 @@ final class AppState { remoteUrl: remoteUrl, remoteHost: remoteHost, remoteTarget: remoteTarget, - remoteIdentity: remoteIdentity) + remoteIdentity: remoteIdentity, + remoteToken: remoteToken) if updated.changed { gateway["remote"] = updated.remote changed = true @@ -697,6 +711,7 @@ extension AppState { state.canvasEnabled = true state.remoteTarget = "user@example.com" state.remoteUrl = "wss://gateway.example.ts.net" + state.remoteToken = "example-token" state.remoteIdentity = "~/.ssh/id_ed25519" state.remoteProjectRoot = "~/Projects/openclaw" state.remoteCliPath = "" @@ -704,6 +719,30 @@ extension AppState { } } +#if DEBUG +@MainActor +extension AppState { + static func _testUpdatedRemoteGatewayConfig( + current: [String: Any], + transport: RemoteTransport, + remoteUrl: String, + remoteHost: String?, + remoteTarget: String, + remoteIdentity: String, + remoteToken: String) -> [String: Any] + { + Self.updatedRemoteGatewayConfig( + current: current, + transport: transport, + remoteUrl: remoteUrl, + remoteHost: remoteHost, + remoteTarget: remoteTarget, + remoteIdentity: remoteIdentity, + remoteToken: remoteToken).remote + } +} +#endif + @MainActor enum AppStateStore { static let shared = AppState() diff --git a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift index 3d044bcda2f..2a9120d8e83 100644 --- a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift +++ b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift @@ -24,6 +24,17 @@ enum GatewayRemoteConfig { return trimmed.isEmpty ? nil : trimmed } + static func resolveTokenString(root: [String: Any]) -> String? { + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let tokenRaw = remote["token"] as? String + else { + return nil + } + let trimmed = tokenRaw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + static func resolveGatewayUrl(root: [String: Any]) -> URL? { guard let raw = self.resolveUrlString(root: root) else { return nil } return self.normalizeGatewayUrl(raw) diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index bdf02d94992..1997b5ba58a 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -149,6 +149,7 @@ struct GeneralSettings: View { } else { self.remoteDirectRow } + self.remoteTokenRow GatewayDiscoveryInlineList( discovery: self.gatewayDiscovery, @@ -291,6 +292,23 @@ struct GeneralSettings: View { } } + private var remoteTokenRow: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 10) { + Text("Gateway token") + .font(.callout.weight(.semibold)) + .frame(width: self.remoteLabelWidth, alignment: .leading) + SecureField("token from gateway.auth.token", text: self.$state.remoteToken) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + } + Text("Used when the remote gateway requires token auth.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, self.remoteLabelWidth + 10) + } + } + private func remoteTestButton(disabled: Bool) -> some View { Button { Task { await self.testRemote() } @@ -692,6 +710,7 @@ extension GeneralSettings { state.remoteTransport = .ssh state.remoteTarget = "user@host:2222" state.remoteUrl = "wss://gateway.example.ts.net" + state.remoteToken = "example-token" state.remoteIdentity = "/tmp/id_ed25519" state.remoteProjectRoot = "/tmp/openclaw" state.remoteCliPath = "/tmp/openclaw" diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 41d28b49092..2a9224699b7 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -199,6 +199,14 @@ extension OnboardingView { .pickerStyle(.segmented) .frame(width: fieldWidth) } + GridRow { + Text("Gateway token") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + SecureField("token from gateway.auth.token", text: self.$state.remoteToken) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } 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 new file mode 100644 index 00000000000..8b903aa2a11 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift @@ -0,0 +1,34 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct AppStateRemoteConfigTests { + @Test + func updatedRemoteGatewayConfigSetsTrimmedToken() { + let remote = AppState._testUpdatedRemoteGatewayConfig( + current: [:], + transport: .ssh, + remoteUrl: "", + remoteHost: "gateway.example", + remoteTarget: "alice@gateway.example", + remoteIdentity: "/tmp/id_ed25519", + remoteToken: " secret-token ") + + #expect(remote["token"] as? String == "secret-token") + } + + @Test + func updatedRemoteGatewayConfigClearsTokenWhenBlank() { + let remote = AppState._testUpdatedRemoteGatewayConfig( + current: ["token": "old-token"], + transport: .direct, + remoteUrl: "wss://gateway.example", + remoteHost: nil, + remoteTarget: "", + remoteIdentity: "", + remoteToken: " ") + + #expect((remote["token"] as? String) == nil) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index 39ca29b33d5..418780c1a70 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -61,7 +61,22 @@ struct GatewayEndpointStoreTests { #expect(token == nil) } - @Test func `resolve gateway password falls back to launchd`() { + @Test func resolveGatewayTokenUsesRemoteConfigToken() { + let token = GatewayEndpointStore._testResolveGatewayToken( + isRemote: true, + root: [ + "gateway": [ + "remote": [ + "token": " remote-token ", + ], + ], + ], + env: [:], + launchdSnapshot: nil) + #expect(token == "remote-token") + } + + @Test func resolveGatewayPasswordFallsBackToLaunchd() { let snapshot = self.makeLaunchAgentSnapshot( env: ["OPENCLAW_GATEWAY_PASSWORD": "launchd-pass"], token: nil,