diff --git a/apps/macos/Sources/OpenClawMacCLI/ConfigureRemoteCommand.swift b/apps/macos/Sources/OpenClawMacCLI/ConfigureRemoteCommand.swift index 243c49df7fad..9e17f0491fea 100644 --- a/apps/macos/Sources/OpenClawMacCLI/ConfigureRemoteCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/ConfigureRemoteCommand.swift @@ -141,7 +141,7 @@ private func configureSSHRemote( userInfo: [NSLocalizedDescriptionKey: "SSH target must look like user@host[:port]"]) } - let configURL = openClawConfigURL() + let configURL = resolveOpenClawConfigURL() var root = try loadConfigRoot(from: configURL) var gateway = root["gateway"] as? [String: Any] ?? [:] var remote = gateway["remote"] as? [String: Any] ?? [:] @@ -201,7 +201,7 @@ private func configureDirectRemote( ]) } - let configURL = openClawConfigURL() + let configURL = resolveOpenClawConfigURL() var root = try loadConfigRoot(from: configURL) var gateway = root["gateway"] as? [String: Any] ?? [:] var remote = gateway["remote"] as? [String: Any] ?? [:] @@ -234,15 +234,6 @@ private func configureDirectRemote( onboardingSkipped: true) } -private func openClawConfigURL() -> URL { - if let raw = ProcessInfo.processInfo.environment["OPENCLAW_CONFIG_PATH"], - !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return URL(fileURLWithPath: NSString(string: raw).expandingTildeInPath) - } - return FileManager().homeDirectoryForCurrentUser.appendingPathComponent(".openclaw/openclaw.json") -} - private func loadConfigRoot(from url: URL) throws -> [String: Any] { guard FileManager().isReadableFile(atPath: url.path) else { return [:] } let data = try Data(contentsOf: url) diff --git a/apps/macos/Sources/OpenClawMacCLI/GatewayConfig.swift b/apps/macos/Sources/OpenClawMacCLI/GatewayConfig.swift index bfe24cd91160..a95cac1e0b5f 100644 --- a/apps/macos/Sources/OpenClawMacCLI/GatewayConfig.swift +++ b/apps/macos/Sources/OpenClawMacCLI/GatewayConfig.swift @@ -19,13 +19,26 @@ struct GatewayEndpoint { let mode: String } +/// Keep standalone CLI reads and configure-remote writes on the same profile. +/// An explicit config path wins; otherwise the selected state directory owns openclaw.json. +func resolveOpenClawConfigURL() -> URL { + if let configPath = openClawEnvironmentPath("OPENCLAW_CONFIG_PATH") { + return URL(fileURLWithPath: NSString(string: configPath).expandingTildeInPath) + } + let stateDir = openClawEnvironmentPath("OPENCLAW_STATE_DIR").map { + URL(fileURLWithPath: NSString(string: $0).expandingTildeInPath, isDirectory: true) + } ?? FileManager().homeDirectoryForCurrentUser.appendingPathComponent(".openclaw", isDirectory: true) + return stateDir.appendingPathComponent("openclaw.json") +} + +private func openClawEnvironmentPath(_ key: String) -> String? { + guard let raw = ProcessInfo.processInfo.environment[key] else { return nil } + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value +} + func loadGatewayConfig() -> GatewayConfig { - let home = FileManager().homeDirectoryForCurrentUser - let candidates = [ - home.appendingPathComponent(".openclaw/openclaw.json"), - ] - let url = candidates.first { FileManager().isReadableFile(atPath: $0.path) } ?? candidates[0] - guard let data = try? Data(contentsOf: url) else { return GatewayConfig() } + guard let data = try? Data(contentsOf: resolveOpenClawConfigURL()) else { return GatewayConfig() } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return GatewayConfig() } diff --git a/apps/macos/Tests/OpenClawIPCTests/ConfigureRemoteCommandTests.swift b/apps/macos/Tests/OpenClawIPCTests/ConfigureRemoteCommandTests.swift index a214f8198261..b31f38611dc3 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ConfigureRemoteCommandTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ConfigureRemoteCommandTests.swift @@ -198,6 +198,35 @@ struct ConfigureRemoteCommandTests { } } + @Test @MainActor func `configure remote writes state dir config when config path is unset`() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-configure-state-\(UUID().uuidString)", isDirectory: true) + let configURL = stateDir.appendingPathComponent("openclaw.json") + defer { try? FileManager().removeItem(at: stateDir) } + + try await TestIsolation.withIsolatedState(env: [ + "OPENCLAW_CONFIG_PATH": nil, + "OPENCLAW_STATE_DIR": stateDir.path, + ]) { + try #require(resolveOpenClawConfigURL().path == configURL.path) + let output = try configureRemote( + .init( + directUrl: "ws://192.168.0.203:18789", + token: "state-dir-token"), // pragma: allowlist secret + defaultsSuites: []) + + #expect(output.configPath == configURL.path) + let data = try Data(contentsOf: configURL) + let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let gateway = try #require(root["gateway"] as? [String: Any]) + let remote = try #require(gateway["remote"] as? [String: Any]) + #expect(gateway["mode"] as? String == "remote") + #expect(remote["transport"] as? String == "direct") + #expect(remote["url"] as? String == "ws://192.168.0.203:18789") + #expect(remote["token"] as? String == "state-dir-token") // pragma: allowlist secret + } + } + @Test @MainActor func `configure remote rejects plaintext public prefix bypass`() async { let configURL = FileManager().temporaryDirectory .appendingPathComponent("openclaw-configure-direct-reject-\(UUID().uuidString).json") @@ -213,3 +242,98 @@ struct ConfigureRemoteCommandTests { } } } + +@Suite(.serialized) +struct GatewayConfigTests { + @Test @MainActor func `config path wins when both config and state dir are set`() async throws { + let rootDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-cli-config-precedence-\(UUID().uuidString)", isDirectory: true) + let explicitConfigURL = rootDir.appendingPathComponent("explicit.json") + let stateConfigURL = rootDir.appendingPathComponent("state/openclaw.json") + defer { try? FileManager().removeItem(at: rootDir) } + + try self.writeGatewayConfig( + to: explicitConfigURL, + port: 19101, + remoteURL: "wss://explicit-config.example:19101", + token: "explicit-config-token") // pragma: allowlist secret + try self.writeGatewayConfig( + to: stateConfigURL, + port: 19102, + remoteURL: "wss://state-config.example:19102", + token: "state-config-token") // pragma: allowlist secret + + await TestIsolation.withEnvValues([ + "OPENCLAW_CONFIG_PATH": explicitConfigURL.path, + "OPENCLAW_STATE_DIR": stateConfigURL.deletingLastPathComponent().path, + ]) { + let config = loadGatewayConfig() + + #expect(config.port == 19101) + #expect(config.remoteUrl == "wss://explicit-config.example:19101") + #expect(config.remoteToken == "explicit-config-token") // pragma: allowlist secret + } + } + + @Test @MainActor func `config path trims surrounding whitespace and expands tilde`() async { + let relativePath = ".openclaw-cli-config-\(UUID().uuidString)/openclaw.json" + let expectedURL = FileManager().homeDirectoryForCurrentUser.appendingPathComponent(relativePath) + + await TestIsolation.withEnvValues([ + "OPENCLAW_CONFIG_PATH": " ~/\(relativePath)\n", + "OPENCLAW_STATE_DIR": "/tmp/openclaw-unused-state-dir", + ]) { + #expect(resolveOpenClawConfigURL().path == expectedURL.path) + } + } + + @Test @MainActor func `state dir trims surrounding whitespace for connect and wizard`() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw cli state profile \(UUID().uuidString)", isDirectory: true) + let configURL = stateDir.appendingPathComponent("openclaw.json") + defer { try? FileManager().removeItem(at: stateDir) } + + try self.writeGatewayConfig( + to: configURL, + port: 19201, + remoteURL: "wss://state-profile.example:19202", + remotePort: 19203, + token: "state-profile-token") // pragma: allowlist secret + + await TestIsolation.withEnvValues([ + "OPENCLAW_CONFIG_PATH": nil, + "OPENCLAW_STATE_DIR": "\t\(stateDir.path) ", + ]) { + let config = loadGatewayConfig() + + #expect(config.mode == "remote") + #expect(config.port == 19201) + #expect(config.remoteUrl == "wss://state-profile.example:19202") + #expect(config.remotePort == 19203) + #expect(config.remoteToken == "state-profile-token") // pragma: allowlist secret + } + } + + private func writeGatewayConfig( + to url: URL, + port: Int, + remoteURL: String, + remotePort: Int = 18789, + token: String) throws + { + let root: [String: Any] = [ + "gateway": [ + "mode": "remote", + "port": port, + "remote": [ + "url": remoteURL, + "remotePort": remotePort, + "token": token, + ], + ], + ] + try FileManager().createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: url, options: [.atomic]) + } +} diff --git a/docs/platforms/mac/remote.md b/docs/platforms/mac/remote.md index 285d6ea9ef96..d8f67f5ea7d3 100644 --- a/docs/platforms/mac/remote.md +++ b/docs/platforms/mac/remote.md @@ -52,7 +52,7 @@ openclaw-mac configure-remote \ --token "$OPENCLAW_GATEWAY_TOKEN" ``` -Both forms write `~/.openclaw/openclaw.json`, mark onboarding complete, and let the app own the selected transport on next start. `--local-port`/`--remote-port` default to `18789`. Other flags: `--password`, `--identity `, `--ssh-host-key-policy `, `--project-root `, `--cli-path `, `--json`. Run `openclaw-mac configure-remote --help` for the full reference. +`openclaw-mac connect`, `wizard`, and `configure-remote` resolve the active config in this order: `OPENCLAW_CONFIG_PATH`, then `$OPENCLAW_STATE_DIR/openclaw.json`, then `~/.openclaw/openclaw.json`. Both configuration forms write that active file, mark onboarding complete, and let the app own the selected transport on next start. `--local-port`/`--remote-port` default to `18789`. Other flags: `--password`, `--identity `, `--ssh-host-key-policy `, `--project-root `, `--cli-path `, `--json`. Run `openclaw-mac configure-remote --help` for the full reference. To configure from the UI instead: