diff --git a/apps/macos/Sources/OpenClaw/PortGuardian.swift b/apps/macos/Sources/OpenClaw/PortGuardian.swift index 383065a8d114..c7db8ba2054f 100644 --- a/apps/macos/Sources/OpenClaw/PortGuardian.swift +++ b/apps/macos/Sources/OpenClaw/PortGuardian.swift @@ -534,6 +534,7 @@ actor PortGuardian { { return true } + if self.isNodeOpenClawGatewayCommand(full) { return true } // If args are unavailable, treat a CLI listener as expected. if cmd.contains("openclaw"), full == cmd { return true } return false @@ -542,6 +543,30 @@ actor PortGuardian { } } + private static func isNodeOpenClawGatewayCommand(_ fullCommand: String) -> Bool { + let tokens = fullCommand + .split(whereSeparator: \.isWhitespace) + .map { self.unquoteCommandToken(String($0)) } + guard tokens.count >= 3 else { return false } + guard URL(fileURLWithPath: tokens[0]).lastPathComponent.lowercased() == "node" else { + return false + } + return self.isOpenClawDistEntrypointToken(tokens[1]) + && tokens[2].lowercased() == "gateway" + } + + private static func isOpenClawDistEntrypointToken(_ token: String) -> Bool { + let normalized = token.replacingOccurrences(of: "\\", with: "/").lowercased() + guard normalized.hasSuffix("/dist/index.js") else { return false } + return normalized + .split(separator: "/", omittingEmptySubsequences: true) + .contains("openclaw") + } + + private static func unquoteCommandToken(_ token: String) -> String { + token.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + } + private func probeGatewayHealthIfNeeded( port: Int, mode: AppState.ConnectionMode, diff --git a/apps/macos/Tests/OpenClawIPCTests/PortGuardianIsExpectedTests.swift b/apps/macos/Tests/OpenClawIPCTests/PortGuardianIsExpectedTests.swift new file mode 100644 index 000000000000..a95cbd17303f --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/PortGuardianIsExpectedTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import OpenClaw + +struct PortGuardianIsExpectedTests { + @Test func `local mode preserves launchd node dist gateway command`() { + let fullCommand = """ + /opt/homebrew/bin/node /opt/homebrew/lib/node_modules/openclaw/dist/index.js gateway --port 18789 --bind loopback + """ + + #expect(PortGuardian._testIsExpected( + command: "node", + fullCommand: fullCommand, + port: 18789, + mode: .local)) + } + + @Test func `local mode preserves git checkout node dist gateway command`() { + let fullCommand = """ + /usr/local/bin/node /Users/dev/Projects/openclaw/dist/index.js gateway --port 18789 + """ + + #expect(PortGuardian._testIsExpected( + command: "node", + fullCommand: fullCommand, + port: 18789, + mode: .local)) + } + + @Test func `local mode rejects similarly named node project`() { + #expect(!PortGuardian._testIsExpected( + command: "node", + fullCommand: "/usr/local/bin/node /tmp/openclaw-tools/dist/index.js gateway --port 18789", + port: 18789, + mode: .local)) + } + + @Test func `local mode rejects gateway appearing after another node argument`() { + #expect(!PortGuardian._testIsExpected( + command: "node", + fullCommand: "/usr/local/bin/node --inspect /tmp/openclaw/dist/index.js gateway --port 18789", + port: 18789, + mode: .local)) + } + + @Test func `local mode rejects node dist entrypoint without gateway subcommand`() { + #expect(!PortGuardian._testIsExpected( + command: "node", + fullCommand: "/opt/homebrew/bin/node /opt/homebrew/lib/node_modules/openclaw/dist/index.js doctor", + port: 18789, + mode: .local)) + } +}