diff --git a/CHANGELOG.md b/CHANGELOG.md index 2533626aa..c7d7ba9ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot ### Fixes - Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee. +- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105) ## 2026.1.18-3 diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift index 6f484cc80..700b79f19 100644 --- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift @@ -16,6 +16,10 @@ enum GatewayLaunchAgentManager { static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { _ = bundlePath + guard !CommandResolver.connectionModeIsRemote() else { + self.logger.info("launchd change skipped (remote mode)") + return nil + } if enabled, self.isLaunchAgentWriteDisabled() { self.logger.info("launchd enable skipped (disable marker set)") return nil @@ -112,7 +116,9 @@ extension GatewayLaunchAgentManager { { let command = CommandResolver.clawdbotCommand( subcommand: "daemon", - extraArgs: self.withJsonFlag(args)) + extraArgs: self.withJsonFlag(args), + // Launchd management must always run locally, even if remote mode is configured. + configRoot: ["gateway": ["mode": "local"]]) var env = ProcessInfo.processInfo.environment env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) diff --git a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift index 8369dbb93..35d81243b 100644 --- a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift @@ -114,6 +114,9 @@ final class GatewayProcessManager { self.lastFailureReason = nil self.status = .stopped self.logger.info("gateway stop requested") + if CommandResolver.connectionModeIsRemote() { + return + } let bundlePath = Bundle.main.bundleURL.path Task { _ = await GatewayLaunchAgentManager.set( diff --git a/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift index ddd94b4d0..8feb00f12 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift @@ -134,4 +134,27 @@ import Testing #expect(script.contains("CLI=")) } } + + @Test func configRootLocalOverridesRemoteDefaults() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey) + defaults.set("clawd@example.com:2222", forKey: remoteTargetKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot") + try self.makeExec(at: clawdbotPath) + + let cmd = CommandResolver.clawdbotCommand( + subcommand: "daemon", + defaults: defaults, + configRoot: ["gateway": ["mode": "local"]]) + + #expect(cmd.first == clawdbotPath.path) + #expect(cmd.count >= 2) + if cmd.count >= 2 { + #expect(cmd[1] == "daemon") + } + } }