Skip to content

Commit 2bfe188

Browse files
teslamintImLukeF
andauthored
fix(macos): prevent PortGuard from killing Docker Desktop in remote mode (openclaw#13798)
fix(macos): prevent PortGuardian from killing Docker Desktop in remote mode (openclaw#6755) PortGuardian.sweep() was killing non-SSH processes holding the gateway port in remote mode. When the gateway runs in a Docker container, `com.docker.backend` owns the port-forward, so this could shut down Docker Desktop entirely. Changes: - accept any process on the gateway port in remote mode - add a defense-in-depth guard to skip kills in remote mode - update remote-mode port diagnostics/reporting to match - add regression coverage for Docker and local-mode behavior - add a changelog entry for the fix Co-Authored-By: ImLukeF <[email protected]>
1 parent e5fe818 commit 2bfe188

File tree

3 files changed

+72
-6
lines changed

3 files changed

+72
-6
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ Docs: https://docs.openclaw.ai
285285
- Agents/failover: classify ZenMux quota-refresh `402` responses as `rate_limit` so model fallback retries continue instead of stopping on a temporary subscription window. (#43917) thanks @bwjoke.
286286
- Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode.
287287
- Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows.
288+
- macOS/remote gateway: stop PortGuardian from killing Docker Desktop and other external listeners on the gateway port in remote mode, so containerized and tunneled gateway setups no longer lose their port-forward owner on app startup. (#6755) Thanks @teslamint.
288289

289290
## 2026.3.8
290291

apps/macos/Sources/OpenClaw/PortGuardian.swift

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,22 @@ actor PortGuardian {
4747
let listeners = await self.listeners(on: port)
4848
guard !listeners.isEmpty else { continue }
4949
for listener in listeners {
50-
if self.isExpected(listener, port: port, mode: mode) {
50+
if Self.isExpected(listener, port: port, mode: mode) {
5151
let message = """
5252
port \(port) already served by expected \(listener.command)
5353
(pid \(listener.pid)) — keeping
5454
"""
5555
self.logger.info("\(message, privacy: .public)")
5656
continue
5757
}
58+
if mode == .remote {
59+
let message = """
60+
port \(port) held by \(listener.command)
61+
(pid \(listener.pid)) in remote mode — not killing
62+
"""
63+
self.logger.warning(message)
64+
continue
65+
}
5866
let killed = await self.kill(listener.pid)
5967
if killed {
6068
let message = """
@@ -271,8 +279,8 @@ actor PortGuardian {
271279

272280
switch mode {
273281
case .remote:
274-
expectedDesc = "SSH tunnel to remote gateway"
275-
okPredicate = { $0.command.lowercased().contains("ssh") }
282+
expectedDesc = "Remote gateway (SSH tunnel, Docker, or direct)"
283+
okPredicate = { _ in true }
276284
case .local:
277285
expectedDesc = "Gateway websocket (node/tsx)"
278286
okPredicate = { listener in
@@ -352,13 +360,12 @@ actor PortGuardian {
352360
return sigkill.ok
353361
}
354362

355-
private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool {
363+
private static func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool {
356364
let cmd = listener.command.lowercased()
357365
let full = listener.fullCommand.lowercased()
358366
switch mode {
359367
case .remote:
360-
// Remote mode expects an SSH tunnel for the gateway WebSocket port.
361-
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
368+
if port == GatewayEnvironment.gatewayPort() { return true }
362369
return false
363370
case .local:
364371
// The gateway daemon may listen as `openclaw` or as its runtime (`node`, `bun`, etc).
@@ -406,6 +413,16 @@ extension PortGuardian {
406413
self.parseListeners(from: text).map { ($0.pid, $0.command, $0.fullCommand, $0.user) }
407414
}
408415

416+
static func _testIsExpected(
417+
command: String,
418+
fullCommand: String,
419+
port: Int,
420+
mode: AppState.ConnectionMode) -> Bool
421+
{
422+
let listener = Listener(pid: 0, command: command, fullCommand: fullCommand, user: nil)
423+
return Self.isExpected(listener, port: port, mode: mode)
424+
}
425+
409426
static func _testBuildReport(
410427
port: Int,
411428
mode: AppState.ConnectionMode,

apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,54 @@ struct LowCoverageHelperTests {
139139
#expect(emptyReport.summary.contains("Nothing is listening"))
140140
}
141141

142+
@Test func `port guardian remote mode does not kill docker`() {
143+
#expect(PortGuardian._testIsExpected(
144+
command: "com.docker.backend",
145+
fullCommand: "com.docker.backend",
146+
port: 18789, mode: .remote) == true)
147+
148+
#expect(PortGuardian._testIsExpected(
149+
command: "ssh",
150+
fullCommand: "ssh -L 18789:localhost:18789 user@host",
151+
port: 18789, mode: .remote) == true)
152+
153+
#expect(PortGuardian._testIsExpected(
154+
command: "podman",
155+
fullCommand: "podman",
156+
port: 18789, mode: .remote) == true)
157+
}
158+
159+
@Test func `port guardian local mode still rejects unexpected`() {
160+
#expect(PortGuardian._testIsExpected(
161+
command: "com.docker.backend",
162+
fullCommand: "com.docker.backend",
163+
port: 18789, mode: .local) == false)
164+
165+
#expect(PortGuardian._testIsExpected(
166+
command: "python",
167+
fullCommand: "python server.py",
168+
port: 18789, mode: .local) == false)
169+
170+
#expect(PortGuardian._testIsExpected(
171+
command: "node",
172+
fullCommand: "node /path/to/gateway-daemon",
173+
port: 18789, mode: .local) == true)
174+
}
175+
176+
@Test func `port guardian remote mode report accepts any listener`() {
177+
let dockerReport = PortGuardian._testBuildReport(
178+
port: 18789, mode: .remote,
179+
listeners: [(pid: 99, command: "com.docker.backend",
180+
fullCommand: "com.docker.backend", user: "me")])
181+
#expect(dockerReport.offenders.isEmpty)
182+
183+
let localDockerReport = PortGuardian._testBuildReport(
184+
port: 18789, mode: .local,
185+
listeners: [(pid: 99, command: "com.docker.backend",
186+
fullCommand: "com.docker.backend", user: "me")])
187+
#expect(!localDockerReport.offenders.isEmpty)
188+
}
189+
142190
@Test @MainActor func `canvas scheme handler resolves files and errors`() throws {
143191
let root = FileManager().temporaryDirectory
144192
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)

0 commit comments

Comments
 (0)