diff --git a/CHANGELOG.md b/CHANGELOG.md index 88b533695..a021a170c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ - Fix: keep background exec aborts from killing backgrounded sessions while honoring timeouts. - Fix: use local auth for gateway security probe unless remote mode has a URL. (#1011) — thanks @ivanrvpereira. - Discord: truncate skill command descriptions for slash command limits. (#1018) — thanks @evalexpr. +- macOS: resolve gateway token/password using config mode/remote URL, and warn when `launchctl setenv` overrides config. (#1022, #1021) — thanks @kkarimi. ## 2026.1.14-1 diff --git a/apps/macos/Sources/Clawdbot/AppState.swift b/apps/macos/Sources/Clawdbot/AppState.swift index dbf29c000..22994bc25 100644 --- a/apps/macos/Sources/Clawdbot/AppState.swift +++ b/apps/macos/Sources/Clawdbot/AppState.swift @@ -257,30 +257,8 @@ final class AppState { let configRoot = ClawdbotConfigFile.loadDict() let configGateway = configRoot["gateway"] as? [String: Any] - let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let configMode: ConnectionMode? = switch configModeRaw { - case "local": - .local - case "remote": - .remote - default: - nil - } let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String - let configHasRemoteUrl = !(configRemoteUrl? - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty ?? true) - - let storedMode = UserDefaults.standard.string(forKey: connectionModeKey) - let resolvedConnectionMode: ConnectionMode = if let configMode { - configMode - } else if configHasRemoteUrl { - .remote - } else if let storedMode { - ConnectionMode(rawValue: storedMode) ?? .local - } else { - onboardingSeen ? .local : .unconfigured - } + let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode self.connectionMode = resolvedConnectionMode let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" diff --git a/apps/macos/Sources/Clawdbot/CommandResolver.swift b/apps/macos/Sources/Clawdbot/CommandResolver.swift index f4fb5c2b1..f1d36a9bc 100644 --- a/apps/macos/Sources/Clawdbot/CommandResolver.swift +++ b/apps/macos/Sources/Clawdbot/CommandResolver.swift @@ -385,14 +385,8 @@ enum CommandResolver { } static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings { - let modeRaw = defaults.string(forKey: connectionModeKey) - let mode: AppState.ConnectionMode - if let modeRaw { - mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local - } else { - let seen = defaults.bool(forKey: "clawdbot.onboardingSeen") - mode = seen ? .local : .unconfigured - } + let root = ClawdbotConfigFile.loadDict() + let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode let target = defaults.string(forKey: remoteTargetKey) ?? "" let identity = defaults.string(forKey: remoteIdentityKey) ?? "" let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? "" diff --git a/apps/macos/Sources/Clawdbot/ConnectionModeResolver.swift b/apps/macos/Sources/Clawdbot/ConnectionModeResolver.swift new file mode 100644 index 000000000..0d5bcbbf9 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ConnectionModeResolver.swift @@ -0,0 +1,50 @@ +import Foundation + +enum EffectiveConnectionModeSource: Sendable, Equatable { + case configMode + case configRemoteURL + case userDefaults + case onboarding +} + +struct EffectiveConnectionMode: Sendable, Equatable { + let mode: AppState.ConnectionMode + let source: EffectiveConnectionModeSource +} + +enum ConnectionModeResolver { + static func resolve( + root: [String: Any], + defaults: UserDefaults = .standard) -> EffectiveConnectionMode + { + let gateway = root["gateway"] as? [String: Any] + let configModeRaw = (gateway?["mode"] as? String) ?? "" + let configMode = configModeRaw + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + switch configMode { + case "local": + return EffectiveConnectionMode(mode: .local, source: .configMode) + case "remote": + return EffectiveConnectionMode(mode: .remote, source: .configMode) + default: + break + } + + let remoteURLRaw = ((gateway?["remote"] as? [String: Any])?["url"] as? String) ?? "" + let remoteURL = remoteURLRaw.trimmingCharacters(in: .whitespacesAndNewlines) + if !remoteURL.isEmpty { + return EffectiveConnectionMode(mode: .remote, source: .configRemoteURL) + } + + if let storedModeRaw = defaults.string(forKey: connectionModeKey) { + let storedMode = AppState.ConnectionMode(rawValue: storedModeRaw) ?? .local + return EffectiveConnectionMode(mode: storedMode, source: .userDefaults) + } + + let seen = defaults.bool(forKey: "clawdbot.onboardingSeen") + return EffectiveConnectionMode(mode: seen ? .local : .unconfigured, source: .onboarding) + } +} + diff --git a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift index 9043fc17e..ff1e666b2 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift @@ -1,3 +1,4 @@ +import ConcurrencyExtras import Foundation import OSLog @@ -16,6 +17,13 @@ actor GatewayEndpointStore { static let shared = GatewayEndpointStore() private static let supportedBindModes: Set = ["loopback", "tailnet", "lan", "auto"] private static let remoteConnectingDetail = "Connecting to remote gateway…" + private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint") + private enum EnvOverrideWarningKind: Sendable { + case token + case password + } + + private static let envOverrideWarnings = LockIsolated((token: false, password: false)) struct Deps: Sendable { let mode: @Sendable () async -> AppState.ConnectionMode @@ -30,16 +38,18 @@ actor GatewayEndpointStore { mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, token: { let root = ClawdbotConfigFile.loadDict() + let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote return GatewayEndpointStore.resolveGatewayToken( - isRemote: CommandResolver.connectionModeIsRemote(), + isRemote: isRemote, root: root, env: ProcessInfo.processInfo.environment, launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) }, password: { let root = ClawdbotConfigFile.loadDict() + let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote return GatewayEndpointStore.resolveGatewayPassword( - isRemote: CommandResolver.connectionModeIsRemote(), + isRemote: isRemote, root: root, env: ProcessInfo.processInfo.environment, launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) @@ -68,6 +78,14 @@ actor GatewayEndpointStore { let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? "" let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { + if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root), + !configPassword.isEmpty + { + self.warnEnvOverrideOnce( + kind: .password, + envVar: "CLAWDBOT_GATEWAY_PASSWORD", + configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password") + } return trimmed } if isRemote { @@ -99,6 +117,26 @@ actor GatewayEndpointStore { return nil } + private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? { + if isRemote { + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let password = remote["password"] as? String + { + return password.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let password = auth["password"] as? String + { + return password.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + private static func resolveGatewayToken( isRemote: Bool, root: [String: Any], @@ -108,6 +146,14 @@ actor GatewayEndpointStore { let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? "" let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { + if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), + !configToken.isEmpty + { + self.warnEnvOverrideOnce( + kind: .token, + envVar: "CLAWDBOT_GATEWAY_TOKEN", + configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token") + } return trimmed } if isRemote { @@ -139,6 +185,49 @@ actor GatewayEndpointStore { return nil } + private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? { + if isRemote { + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let token = remote["token"] as? String + { + return token.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let token = auth["token"] as? String + { + return token.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + private static func warnEnvOverrideOnce( + kind: EnvOverrideWarningKind, + envVar: String, + configKey: String) + { + let shouldWarn = Self.envOverrideWarnings.withValue { state in + switch kind { + case .token: + guard !state.token else { return false } + state.token = true + return true + case .password: + guard !state.password else { return false } + state.password = true + return true + } + } + guard shouldWarn else { return } + Self.staticLogger.warning( + "\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " + + "If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)") + } + private let deps: Deps private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint") diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift index dd795f80c..da310a8b3 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift @@ -3,6 +3,13 @@ import Testing @testable import Clawdbot @Suite struct GatewayEndpointStoreTests { + private func makeDefaults() -> UserDefaults { + let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + @Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() { let snapshot = LaunchAgentPlistSnapshot( programArguments: [], @@ -66,4 +73,70 @@ import Testing launchdSnapshot: snapshot) #expect(password == "launchd-pass") } + + @Test func connectionModeResolverPrefersConfigModeOverDefaults() { + let defaults = self.makeDefaults() + defaults.set("remote", forKey: connectionModeKey) + + let root: [String: Any] = [ + "gateway": [ + "mode": " local ", + ], + ] + + let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) + #expect(resolved.mode == .local) + } + + @Test func connectionModeResolverTrimsConfigMode() { + let defaults = self.makeDefaults() + defaults.set("local", forKey: connectionModeKey) + + let root: [String: Any] = [ + "gateway": [ + "mode": " remote ", + ], + ] + + let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) + #expect(resolved.mode == .remote) + } + + @Test func connectionModeResolverFallsBackToDefaultsWhenMissingConfig() { + let defaults = self.makeDefaults() + defaults.set("remote", forKey: connectionModeKey) + + let resolved = ConnectionModeResolver.resolve(root: [:], defaults: defaults) + #expect(resolved.mode == .remote) + } + + @Test func connectionModeResolverFallsBackToDefaultsOnUnknownConfig() { + let defaults = self.makeDefaults() + defaults.set("local", forKey: connectionModeKey) + + let root: [String: Any] = [ + "gateway": [ + "mode": "staging", + ], + ] + + let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) + #expect(resolved.mode == .local) + } + + @Test func connectionModeResolverPrefersRemoteURLWhenModeMissing() { + let defaults = self.makeDefaults() + defaults.set("local", forKey: connectionModeKey) + + let root: [String: Any] = [ + "gateway": [ + "remote": [ + "url": " ws://umbrel:18789 ", + ], + ], + ] + + let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) + #expect(resolved.mode == .remote) + } } diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index a5673eeec..51fef8d22 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -21,3 +21,14 @@ clawdbot doctor --repair clawdbot doctor --deep ``` +## macOS: `launchctl` env overrides + +If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors. + +```bash +launchctl getenv CLAWDBOT_GATEWAY_TOKEN +launchctl getenv CLAWDBOT_GATEWAY_PASSWORD + +launchctl unsetenv CLAWDBOT_GATEWAY_TOKEN +launchctl unsetenv CLAWDBOT_GATEWAY_PASSWORD +``` diff --git a/src/commands/doctor-platform-notes.ts b/src/commands/doctor-platform-notes.ts index 20458f445..1fdcada1a 100644 --- a/src/commands/doctor-platform-notes.ts +++ b/src/commands/doctor-platform-notes.ts @@ -1,9 +1,14 @@ +import { execFile } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; +import type { ClawdbotConfig } from "../config/config.js"; import { note } from "../terminal/note.js"; +const execFileAsync = promisify(execFile); + function resolveHomeDir(): string { return process.env.HOME ?? os.homedir(); } @@ -21,3 +26,47 @@ export async function noteMacLaunchAgentOverrides() { ].filter((line): line is string => Boolean(line)); note(lines.join("\n"), "Gateway (macOS)"); } + +async function launchctlGetenv(name: string): Promise { + try { + const result = await execFileAsync("/bin/launchctl", ["getenv", name], { encoding: "utf8" }); + const value = String(result.stdout ?? "").trim(); + return value.length > 0 ? value : undefined; + } catch { + return undefined; + } +} + +function hasConfigGatewayCreds(cfg: ClawdbotConfig): boolean { + const localToken = + typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway?.auth?.token.trim() : ""; + const localPassword = + typeof cfg.gateway?.auth?.password === "string" ? cfg.gateway?.auth?.password.trim() : ""; + const remoteToken = + typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway?.remote?.token.trim() : ""; + const remotePassword = + typeof cfg.gateway?.remote?.password === "string" ? cfg.gateway?.remote?.password.trim() : ""; + return Boolean(localToken || localPassword || remoteToken || remotePassword); +} + +export async function noteMacLaunchctlGatewayEnvOverrides(cfg: ClawdbotConfig) { + if (process.platform !== "darwin") return; + if (!hasConfigGatewayCreds(cfg)) return; + + const envToken = await launchctlGetenv("CLAWDBOT_GATEWAY_TOKEN"); + const envPassword = await launchctlGetenv("CLAWDBOT_GATEWAY_PASSWORD"); + if (!envToken && !envPassword) return; + + const lines = [ + "- launchctl environment overrides detected (can cause confusing unauthorized errors).", + envToken ? "- `CLAWDBOT_GATEWAY_TOKEN` is set; it overrides config tokens." : undefined, + envPassword + ? "- `CLAWDBOT_GATEWAY_PASSWORD` is set; it overrides config passwords." + : undefined, + "- Clear overrides and restart the app/gateway:", + envToken ? " launchctl unsetenv CLAWDBOT_GATEWAY_TOKEN" : undefined, + envPassword ? " launchctl unsetenv CLAWDBOT_GATEWAY_PASSWORD" : undefined, + ].filter((line): line is string => Boolean(line)); + + note(lines.join("\n"), "Gateway (macOS)"); +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 31439e310..b6a49229b 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -26,7 +26,10 @@ import { maybeScanExtraGatewayServices, } from "./doctor-gateway-services.js"; import { noteSourceInstallIssues } from "./doctor-install.js"; -import { noteMacLaunchAgentOverrides } from "./doctor-platform-notes.js"; +import { + noteMacLaunchAgentOverrides, + noteMacLaunchctlGatewayEnvOverrides, +} from "./doctor-platform-notes.js"; import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js"; import { maybeRepairSandboxImages, noteSandboxScopeWarnings } from "./doctor-sandbox.js"; import { noteSecurityWarnings } from "./doctor-security.js"; @@ -160,6 +163,7 @@ export async function doctorCommand( await maybeScanExtraGatewayServices(options); await maybeRepairGatewayServiceConfig(cfg, resolveMode(cfg), runtime, prompter); await noteMacLaunchAgentOverrides(); + await noteMacLaunchctlGatewayEnvOverrides(cfg); await noteSecurityWarnings(cfg);