fix(macos): prevent PortGuard from killing Docker Desktop in remote mode (#13798)

fix(macos): prevent PortGuardian from killing Docker Desktop in remote mode (#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 <92253590+ImLukeF@users.noreply.github.com>
This commit is contained in:
Jaehoon You
2026-03-14 10:26:09 +09:00
committed by GitHub
parent e5fe818a74
commit 2bfe188510
3 changed files with 72 additions and 6 deletions

View File

@@ -285,6 +285,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
## 2026.3.8

View File

@@ -47,7 +47,7 @@ actor PortGuardian {
let listeners = await self.listeners(on: port)
guard !listeners.isEmpty else { continue }
for listener in listeners {
if self.isExpected(listener, port: port, mode: mode) {
if Self.isExpected(listener, port: port, mode: mode) {
let message = """
port \(port) already served by expected \(listener.command)
(pid \(listener.pid)) — keeping
@@ -55,6 +55,14 @@ actor PortGuardian {
self.logger.info("\(message, privacy: .public)")
continue
}
if mode == .remote {
let message = """
port \(port) held by \(listener.command)
(pid \(listener.pid)) in remote mode — not killing
"""
self.logger.warning(message)
continue
}
let killed = await self.kill(listener.pid)
if killed {
let message = """
@@ -271,8 +279,8 @@ actor PortGuardian {
switch mode {
case .remote:
expectedDesc = "SSH tunnel to remote gateway"
okPredicate = { $0.command.lowercased().contains("ssh") }
expectedDesc = "Remote gateway (SSH tunnel, Docker, or direct)"
okPredicate = { _ in true }
case .local:
expectedDesc = "Gateway websocket (node/tsx)"
okPredicate = { listener in
@@ -352,13 +360,12 @@ actor PortGuardian {
return sigkill.ok
}
private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool {
private static func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool {
let cmd = listener.command.lowercased()
let full = listener.fullCommand.lowercased()
switch mode {
case .remote:
// Remote mode expects an SSH tunnel for the gateway WebSocket port.
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
if port == GatewayEnvironment.gatewayPort() { return true }
return false
case .local:
// The gateway daemon may listen as `openclaw` or as its runtime (`node`, `bun`, etc).
@@ -406,6 +413,16 @@ extension PortGuardian {
self.parseListeners(from: text).map { ($0.pid, $0.command, $0.fullCommand, $0.user) }
}
static func _testIsExpected(
command: String,
fullCommand: String,
port: Int,
mode: AppState.ConnectionMode) -> Bool
{
let listener = Listener(pid: 0, command: command, fullCommand: fullCommand, user: nil)
return Self.isExpected(listener, port: port, mode: mode)
}
static func _testBuildReport(
port: Int,
mode: AppState.ConnectionMode,

View File

@@ -139,6 +139,54 @@ struct LowCoverageHelperTests {
#expect(emptyReport.summary.contains("Nothing is listening"))
}
@Test func `port guardian remote mode does not kill docker`() {
#expect(PortGuardian._testIsExpected(
command: "com.docker.backend",
fullCommand: "com.docker.backend",
port: 18789, mode: .remote) == true)
#expect(PortGuardian._testIsExpected(
command: "ssh",
fullCommand: "ssh -L 18789:localhost:18789 user@host",
port: 18789, mode: .remote) == true)
#expect(PortGuardian._testIsExpected(
command: "podman",
fullCommand: "podman",
port: 18789, mode: .remote) == true)
}
@Test func `port guardian local mode still rejects unexpected`() {
#expect(PortGuardian._testIsExpected(
command: "com.docker.backend",
fullCommand: "com.docker.backend",
port: 18789, mode: .local) == false)
#expect(PortGuardian._testIsExpected(
command: "python",
fullCommand: "python server.py",
port: 18789, mode: .local) == false)
#expect(PortGuardian._testIsExpected(
command: "node",
fullCommand: "node /path/to/gateway-daemon",
port: 18789, mode: .local) == true)
}
@Test func `port guardian remote mode report accepts any listener`() {
let dockerReport = PortGuardian._testBuildReport(
port: 18789, mode: .remote,
listeners: [(pid: 99, command: "com.docker.backend",
fullCommand: "com.docker.backend", user: "me")])
#expect(dockerReport.offenders.isEmpty)
let localDockerReport = PortGuardian._testBuildReport(
port: 18789, mode: .local,
listeners: [(pid: 99, command: "com.docker.backend",
fullCommand: "com.docker.backend", user: "me")])
#expect(!localDockerReport.offenders.isEmpty)
}
@Test @MainActor func `canvas scheme handler resolves files and errors`() throws {
let root = FileManager().temporaryDirectory
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)