macos: add remote gateway token field for remote mode

This commit is contained in:
Charles Dusek
2026-03-04 09:31:08 -06:00
committed by Nimrod Gutman
parent 9d467d1620
commit 6b338dd283
6 changed files with 129 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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