From ae45eebef1839ec51940149ab5760371e02ad8a0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 05:25:13 +0100 Subject: [PATCH] fix: route remote mac browser through node host --- CHANGELOG.md | 4 ++ .../NodeMode/MacNodeBrowserProxy.swift | 24 +++++++++- .../NodeMode/MacNodeModeCoordinator.swift | 29 +++++++++--- .../MacNodeBrowserProxyTests.swift | 24 ++++++++++ .../MacNodeModeCoordinatorTests.swift | 32 +++++++++++++ docs/cli/node.md | 1 + docs/nodes/index.md | 7 ++- docs/platforms/mac/remote.md | 9 +++- src/cli/node-cli/register.test.ts | 45 +++++++++++++++++++ src/cli/node-cli/register.ts | 10 +++++ 10 files changed, 175 insertions(+), 10 deletions(-) create mode 100644 apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift create mode 100644 src/cli/node-cli/register.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ca226071daa..e4335f57510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,10 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/groups: treat clean empty assistant stops as silent `NO_REPLY` only for always-on groups where silent replies are allowed, while keeping direct and mention-gated sessions on the incomplete-turn retry path. Thanks @MagnaAI. +- macOS/Node: keep native remote app nodes from advertising `browser.proxy`, + start browser-capable CLI node services through the restored + `openclaw node start` command, and show an actionable browser-control error + when the local control service is missing. Fixes #66637. - Providers/Z.AI: map OpenClaw thinking controls to Z.AI's `thinking` payload and add opt-in preserved thinking replay via `params.preserveThinking`, so GLM 5.x can keep prior `reasoning_content` when requested. Fixes #58680. Thanks @xuanmingguo. - Channels/status: keep read-only channel lists on manifest and package metadata by default, loading setup runtime only for explicit fallback callers. Thanks @shakkernerd. - Plugins/onboarding: defer onboarding install-record index writes until the guarded config commit so setup failures cannot leave the plugin index ahead of `openclaw.json`. Thanks @shakkernerd. diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift index b43141a3139..b3f8edc62c1 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift @@ -54,8 +54,15 @@ actor MacNodeBrowserProxy { func request(paramsJSON: String?) async throws -> String { let params = try Self.decodeRequestParams(from: paramsJSON) - let request = try Self.makeRequest(params: params, endpoint: self.endpointProvider()) - let (data, response) = try await self.performRequest(request) + let endpoint = self.endpointProvider() + let request = try Self.makeRequest(params: params, endpoint: endpoint) + let data: Data + let response: URLResponse + do { + (data, response) = try await self.performRequest(request) + } catch { + throw Self.unavailableError(endpoint: endpoint, cause: error) + } let http = try Self.requireHTTPResponse(response) guard (200..<300).contains(http.statusCode) else { throw NSError(domain: "MacNodeBrowserProxy", code: http.statusCode, userInfo: [ @@ -165,6 +172,19 @@ actor MacNodeBrowserProxy { return http } + private static func unavailableError(endpoint: Endpoint, cause: Error) -> NSError { + let url = endpoint.baseURL.absoluteString + let message = """ + UNAVAILABLE: macOS app node could not reach the local browser control service at \(url). \ + In remote mode, browser control is owned by the CLI node-host; start `openclaw node start` \ + on this Mac and target that browser node. Underlying error: \(cause.localizedDescription) + """ + return NSError(domain: "MacNodeBrowserProxy", code: 9, userInfo: [ + NSLocalizedDescriptionKey: message, + NSUnderlyingErrorKey: cause, + ]) + } + private static func httpErrorMessage(statusCode: Int, data: Data) -> String { if let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? [String: Any], let error = object["error"] as? String, diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift index 9ae03784a42..728fa3f75d0 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift @@ -116,27 +116,40 @@ final class MacNodeModeCoordinator { } } - private func currentCaps() -> [String] { + nonisolated static func resolvedCaps( + browserControlEnabled: Bool, + cameraEnabled: Bool, + locationMode: OpenClawLocationMode, + connectionMode: AppState.ConnectionMode) -> [String] + { var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue] - if OpenClawConfigFile.browserControlEnabled() { + if browserControlEnabled, connectionMode == .local { caps.append(OpenClawCapability.browser.rawValue) } - if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false { + if cameraEnabled { caps.append(OpenClawCapability.camera.rawValue) } - let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" - if OpenClawLocationMode(rawValue: rawLocationMode) != .off { + if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) } return caps } + private func currentCaps() -> [String] { + let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" + return Self.resolvedCaps( + browserControlEnabled: OpenClawConfigFile.browserControlEnabled(), + cameraEnabled: UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false, + locationMode: OpenClawLocationMode(rawValue: rawLocationMode) ?? .off, + connectionMode: AppStateStore.shared.connectionMode) + } + private func currentPermissions() async -> [String: Bool] { let statuses = await PermissionManager.status() return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) }) } - private func currentCommands(caps: [String]) -> [String] { + nonisolated static func resolvedCommands(caps: [String]) -> [String] { var commands: [String] = [ OpenClawCanvasCommand.present.rawValue, OpenClawCanvasCommand.hide.rawValue, @@ -171,6 +184,10 @@ final class MacNodeModeCoordinator { return commands } + private func currentCommands(caps: [String]) -> [String] { + Self.resolvedCommands(caps: caps) + } + private func buildSessionBox(url: URL) -> WebSocketSessionBox? { guard url.scheme?.lowercased() == "wss" else { return nil } let host = url.host ?? "gateway" diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift index b341263b21f..65a16dc0bac 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift @@ -83,4 +83,28 @@ struct MacNodeBrowserProxyTests { let arr = try #require(parsed["arr"] as? [Any]) #expect(arr.count == 2) } + + @Test func requestReportsActionableUnavailableWhenControlServiceIsMissing() async throws { + let proxy = MacNodeBrowserProxy( + endpointProvider: { + MacNodeBrowserProxy.Endpoint( + baseURL: URL(string: "http://127.0.0.1:18791")!, + token: nil, + password: nil) + }, + performRequest: { _ in + throw URLError(.cannotConnectToHost) + }) + + do { + _ = try await proxy.request(paramsJSON: #"{"method":"GET","path":"/"}"#) + Issue.record("request should fail when browser control is unreachable") + } catch { + let message = error.localizedDescription + #expect(message.contains("UNAVAILABLE: macOS app node could not reach the local browser control service")) + #expect(message.contains("http://127.0.0.1:18791")) + #expect(message.contains("browser control is owned by the CLI node-host")) + #expect(message.contains("openclaw node start")) + } + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift new file mode 100644 index 00000000000..fb95ce8f977 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift @@ -0,0 +1,32 @@ +import Foundation +import OpenClawKit +import Testing +@testable import OpenClaw + +struct MacNodeModeCoordinatorTests { + @Test func remoteModeDoesNotAdvertiseBrowserProxy() { + let caps = MacNodeModeCoordinator.resolvedCaps( + browserControlEnabled: true, + cameraEnabled: false, + locationMode: .off, + connectionMode: .remote) + let commands = MacNodeModeCoordinator.resolvedCommands(caps: caps) + + #expect(!caps.contains(OpenClawCapability.browser.rawValue)) + #expect(!commands.contains(OpenClawBrowserCommand.proxy.rawValue)) + #expect(commands.contains(OpenClawCanvasCommand.present.rawValue)) + #expect(commands.contains(OpenClawSystemCommand.notify.rawValue)) + } + + @Test func localModeAdvertisesBrowserProxyWhenEnabled() { + let caps = MacNodeModeCoordinator.resolvedCaps( + browserControlEnabled: true, + cameraEnabled: false, + locationMode: .off, + connectionMode: .local) + let commands = MacNodeModeCoordinator.resolvedCommands(caps: caps) + + #expect(caps.contains(OpenClawCapability.browser.rawValue)) + #expect(commands.contains(OpenClawBrowserCommand.proxy.rawValue)) + } +} diff --git a/docs/cli/node.md b/docs/cli/node.md index b0cf80e2d96..5ca50cd8937 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -104,6 +104,7 @@ Manage the service: ```bash openclaw node status +openclaw node start openclaw node stop openclaw node restart openclaw node uninstall diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 1ffbc7b9787..7d0ab193bc3 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -12,7 +12,11 @@ A **node** is a companion device (macOS/iOS/Android/headless) that connects to t Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; historical only for current nodes). -macOS can also run in **node mode**: the menubar app connects to the Gateway’s WS server and exposes its local canvas/camera commands as a node (so `openclaw nodes …` works against this Mac). +macOS can also run in **node mode**: the menubar app connects to the Gateway’s +WS server and exposes its local canvas/camera commands as a node (so +`openclaw nodes …` works against this Mac). In remote gateway mode, browser +automation is handled by the CLI node host (`openclaw node run` or the +installed node service), not by the native app node. Notes: @@ -112,6 +116,7 @@ Notes: ```bash openclaw node install --host --port 18789 --display-name "Build Node" +openclaw node start openclaw node restart ``` diff --git a/docs/platforms/mac/remote.md b/docs/platforms/mac/remote.md index 1af309b8833..ce4dbc46bb2 100644 --- a/docs/platforms/mac/remote.md +++ b/docs/platforms/mac/remote.md @@ -25,7 +25,14 @@ Remote mode supports two transports: In SSH tunnel mode, discovered LAN/tailnet hostnames are saved as `gateway.remote.sshTarget`. The app keeps `gateway.remote.url` on the local tunnel endpoint, for example `ws://127.0.0.1:18789`, so CLI, Web Chat, and -browser automation all use the same safe loopback transport. +the local node-host service all use the same safe loopback transport. + +Browser automation in remote mode is owned by the CLI node host, not by the +native macOS app node. The app starts the installed node host service when +possible; if you need browser control from that Mac, install/start it with +`openclaw node install ...` and `openclaw node start` (or run +`openclaw node run ...` in the foreground), then target that browser-capable +node. ## Prereqs on the remote host diff --git a/src/cli/node-cli/register.test.ts b/src/cli/node-cli/register.test.ts new file mode 100644 index 00000000000..f202fb1fbd7 --- /dev/null +++ b/src/cli/node-cli/register.test.ts @@ -0,0 +1,45 @@ +import { Command } from "commander"; +import { describe, expect, it, vi } from "vitest"; +import { registerNodeCli } from "./register.js"; + +const daemonMocks = vi.hoisted(() => ({ + runNodeDaemonInstall: vi.fn(), + runNodeDaemonRestart: vi.fn(), + runNodeDaemonStart: vi.fn(), + runNodeDaemonStatus: vi.fn(), + runNodeDaemonStop: vi.fn(), + runNodeDaemonUninstall: vi.fn(), +})); + +vi.mock("./daemon.js", () => daemonMocks); + +vi.mock("../../node-host/config.js", () => ({ + loadNodeHostConfig: vi.fn(async () => null), +})); + +vi.mock("../../node-host/runner.js", () => ({ + runNodeHost: vi.fn(), +})); + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ + writeErr: () => undefined, + writeOut: () => undefined, + }); + registerNodeCli(program); + return program; +} + +describe("registerNodeCli", () => { + it("registers node start for the macOS app node service manager", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "start", "--json"], { from: "user" }); + + expect(daemonMocks.runNodeDaemonStart).toHaveBeenCalledWith( + expect.objectContaining({ json: true }), + ); + }); +}); diff --git a/src/cli/node-cli/register.ts b/src/cli/node-cli/register.ts index 200aa951d79..4b5b00633b9 100644 --- a/src/cli/node-cli/register.ts +++ b/src/cli/node-cli/register.ts @@ -9,6 +9,7 @@ import { formatHelpExamples } from "../help-format.js"; import { runNodeDaemonInstall, runNodeDaemonRestart, + runNodeDaemonStart, runNodeDaemonStatus, runNodeDaemonStop, runNodeDaemonUninstall, @@ -33,6 +34,7 @@ export function registerNodeCli(program: Command) { ], ["openclaw node status", "Check node host service status."], ["openclaw node install", "Install the node host service."], + ["openclaw node start", "Start the installed node host service."], ["openclaw node restart", "Restart the installed node host service."], ])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/node", "docs.openclaw.ai/cli/node")}\n`, ); @@ -103,6 +105,14 @@ export function registerNodeCli(program: Command) { await runNodeDaemonStop(opts); }); + node + .command("start") + .description("Start the node host service (launchd/systemd/schtasks)") + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runNodeDaemonStart(opts); + }); + node .command("restart") .description("Restart the node host service (launchd/systemd/schtasks)")