From 9387ecf0437341d4fabd1381cc475890b93bb94c Mon Sep 17 00:00:00 2001 From: jeffersonwarrior Date: Thu, 1 Jan 2026 21:26:37 -0600 Subject: [PATCH 01/44] fix(macos): support password auth mode for gateway connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GatewayChannel now sends both 'token' and 'password' fields in the auth payload to support both authentication modes. Gateway checks the field matching its auth.mode configuration ('token' or 'password'). Also adds config file password fallback for remote mode, allowing gateway password to be configured in ~/.clawdis/clawdis.json without requiring environment variables. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/macos/Sources/Clawdis/ClawdisConfigFile.swift | 9 +++++++++ apps/macos/Sources/Clawdis/GatewayChannel.swift | 8 +++++++- apps/macos/Sources/Clawdis/GatewayEndpointStore.swift | 8 +++++++- src/gateway/hooks-mapping.ts | 4 ++-- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift b/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift index 3a712ebce..e0b3532ad 100644 --- a/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift +++ b/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift @@ -97,4 +97,13 @@ enum ClawdisConfigFile { self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)") } + static func gatewayPassword() -> String? { + let root = self.loadDict() + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any] else { + return nil + } + return remote["password"] as? String + } + } diff --git a/apps/macos/Sources/Clawdis/GatewayChannel.swift b/apps/macos/Sources/Clawdis/GatewayChannel.swift index 4de00bd3e..af1c1635c 100644 --- a/apps/macos/Sources/Clawdis/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdis/GatewayChannel.swift @@ -213,7 +213,13 @@ actor GatewayChannelActor { "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), ] if let token = self.token { - params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)]) + // Send both 'token' and 'password' to support both auth modes. + // Gateway checks the field matching its auth.mode configuration. + let authDict: [String: ProtoAnyCodable] = [ + "token": ProtoAnyCodable(token), + "password": ProtoAnyCodable(token), + ] + params["auth"] = ProtoAnyCodable(authDict) } let frame = RequestFrame( diff --git a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift index 192eb3838..b0eec702a 100644 --- a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift @@ -23,7 +23,13 @@ actor GatewayEndpointStore { static let live = Deps( mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, - token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] }, + token: { + // First check env var, fallback to config file + if let envToken = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"], !envToken.isEmpty { + return envToken + } + return ClawdisConfigFile.gatewayPassword() + }, localPort: { GatewayEnvironment.gatewayPort() }, remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() }) diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 1d29d786b..81c001878 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -18,7 +18,7 @@ export type HookMappingResolved = { messageTemplate?: string; textTemplate?: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram" | "discord"; + channel?: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; to?: string; thinking?: string; timeoutSeconds?: number; @@ -50,7 +50,7 @@ export type HookAction = wakeMode: "now" | "next-heartbeat"; sessionKey?: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram" | "discord"; + channel?: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; to?: string; thinking?: string; timeoutSeconds?: number; From fe87d6d8be754a030cd5e738b00a82e47a638449 Mon Sep 17 00:00:00 2001 From: Jefferson Nunn Date: Thu, 1 Jan 2026 21:34:46 -0600 Subject: [PATCH 02/44] feat(macOS): add gateway password auth support and fix Swift 6.2 concurrency - Add CLAWDIS_GATEWAY_PASSWORD to launchd plist environment - Read password from gateway.remote.password config in client - Fix Swift 6.2 sending parameter violations in config save functions - Add password parameter to GatewayConnection.Config type - GatewayChannel now sends password in connect auth params - GatewayEndpointStore and GatewayLaunchAgentManager read password from config - CLI gateway client reads password from remote config and env --- .gitignore | 1 + .../Sources/Clawdis/ConfigSettings.swift | 67 ++++++++++++++++--- apps/macos/Sources/Clawdis/ConfigStore.swift | 2 +- .../Sources/Clawdis/GatewayChannel.swift | 13 ++-- .../Sources/Clawdis/GatewayConnection.swift | 15 +++-- .../Clawdis/GatewayEndpointStore.swift | 58 ++++++++++++---- .../Clawdis/GatewayLaunchAgentManager.swift | 25 +++++++ .../Sources/Clawdis/MenuContentView.swift | 11 ++- .../Clawdis/OnboardingView+Workspace.swift | 17 +++-- .../Clawdis/TailscaleIntegrationSection.swift | 51 +++++++++----- src/canvas-host/a2ui/.bundle.hash | 2 +- src/gateway/call.ts | 2 +- 12 files changed, 203 insertions(+), 61 deletions(-) diff --git a/.gitignore b/.gitignore index 148918225..769176832 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ apps/ios/*.dSYM.zip # provisioning profiles (local) apps/ios/*.mobileprovision +.env diff --git a/apps/macos/Sources/Clawdis/ConfigSettings.swift b/apps/macos/Sources/Clawdis/ConfigSettings.swift index 1cd3f9569..9dc2c4d01 100644 --- a/apps/macos/Sources/Clawdis/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdis/ConfigSettings.swift @@ -431,12 +431,56 @@ struct ConfigSettings: View { self.configSaving = true defer { self.configSaving = false } + let configModel = self.configModel + let customModel = self.customModel + let heartbeatMinutes = self.heartbeatMinutes + let heartbeatBody = self.heartbeatBody + let browserEnabled = self.browserEnabled + let browserControlUrl = self.browserControlUrl + let browserColorHex = self.browserColorHex + let browserAttachOnly = self.browserAttachOnly + let talkVoiceId = self.talkVoiceId + let talkApiKey = self.talkApiKey + let talkInterruptOnSpeech = self.talkInterruptOnSpeech + + let errorMessage = await ConfigSettings.buildAndSaveConfig( + configModel: configModel, + customModel: customModel, + heartbeatMinutes: heartbeatMinutes, + heartbeatBody: heartbeatBody, + browserEnabled: browserEnabled, + browserControlUrl: browserControlUrl, + browserColorHex: browserColorHex, + browserAttachOnly: browserAttachOnly, + talkVoiceId: talkVoiceId, + talkApiKey: talkApiKey, + talkInterruptOnSpeech: talkInterruptOnSpeech + ) + + if let errorMessage { + self.modelError = errorMessage + } + } + + private nonisolated static func buildAndSaveConfig( + configModel: String, + customModel: String, + heartbeatMinutes: Int?, + heartbeatBody: String, + browserEnabled: Bool, + browserControlUrl: String, + browserColorHex: String, + browserAttachOnly: Bool, + talkVoiceId: String, + talkApiKey: String, + talkInterruptOnSpeech: Bool + ) async -> String? { var root = await ConfigStore.load() var agent = root["agent"] as? [String: Any] ?? [:] var browser = root["browser"] as? [String: Any] ?? [:] var talk = root["talk"] as? [String: Any] ?? [:] - let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel) + let chosenModel = (configModel == "__custom__" ? customModel : configModel) .trimmingCharacters(in: .whitespacesAndNewlines) let trimmedModel = chosenModel if !trimmedModel.isEmpty { agent["model"] = trimmedModel } @@ -445,40 +489,41 @@ struct ConfigSettings: View { agent["heartbeatMinutes"] = heartbeatMinutes } - let trimmedBody = self.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedBody = heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedBody.isEmpty { agent["heartbeatBody"] = trimmedBody } root["agent"] = agent - browser["enabled"] = self.browserEnabled - let trimmedUrl = self.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines) + browser["enabled"] = browserEnabled + let trimmedUrl = browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl } - let trimmedColor = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedColor = browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedColor.isEmpty { browser["color"] = trimmedColor } - browser["attachOnly"] = self.browserAttachOnly + browser["attachOnly"] = browserAttachOnly root["browser"] = browser - let trimmedVoice = self.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedVoice = talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedVoice.isEmpty { talk.removeValue(forKey: "voiceId") } else { talk["voiceId"] = trimmedVoice } - let trimmedApiKey = self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedApiKey = talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedApiKey.isEmpty { talk.removeValue(forKey: "apiKey") } else { talk["apiKey"] = trimmedApiKey } - talk["interruptOnSpeech"] = self.talkInterruptOnSpeech + talk["interruptOnSpeech"] = talkInterruptOnSpeech root["talk"] = talk do { try await ConfigStore.save(root) - } catch { - self.modelError = error.localizedDescription + return nil + } catch let error { + return error.localizedDescription } } diff --git a/apps/macos/Sources/Clawdis/ConfigStore.swift b/apps/macos/Sources/Clawdis/ConfigStore.swift index 533ee6825..d06c77609 100644 --- a/apps/macos/Sources/Clawdis/ConfigStore.swift +++ b/apps/macos/Sources/Clawdis/ConfigStore.swift @@ -43,7 +43,7 @@ enum ConfigStore { } @MainActor - static func save(_ root: [String: Any]) async throws { + static func save(_ root: sending [String: Any]) async throws { let overrides = await self.overrideStore.overrides if await self.isRemoteMode() { if let override = overrides.saveRemote { diff --git a/apps/macos/Sources/Clawdis/GatewayChannel.swift b/apps/macos/Sources/Clawdis/GatewayChannel.swift index af1c1635c..7786b30a0 100644 --- a/apps/macos/Sources/Clawdis/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdis/GatewayChannel.swift @@ -66,6 +66,7 @@ actor GatewayChannelActor { private var connectWaiters: [CheckedContinuation] = [] private var url: URL private var token: String? + private var password: String? private let session: WebSocketSessioning private var backoffMs: Double = 500 private var shouldReconnect = true @@ -82,11 +83,13 @@ actor GatewayChannelActor { init( url: URL, token: String?, + password: String? = nil, session: WebSocketSessionBox? = nil, pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil) { self.url = url self.token = token + self.password = password self.session = session?.session ?? URLSession(configuration: .default) self.pushHandler = pushHandler Task { [weak self] in @@ -213,13 +216,9 @@ actor GatewayChannelActor { "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), ] if let token = self.token { - // Send both 'token' and 'password' to support both auth modes. - // Gateway checks the field matching its auth.mode configuration. - let authDict: [String: ProtoAnyCodable] = [ - "token": ProtoAnyCodable(token), - "password": ProtoAnyCodable(token), - ] - params["auth"] = ProtoAnyCodable(authDict) + params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)]) + } else if let password = self.password { + params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) } let frame = RequestFrame( diff --git a/apps/macos/Sources/Clawdis/GatewayConnection.swift b/apps/macos/Sources/Clawdis/GatewayConnection.swift index 52ab9ce6f..b5ee499e7 100644 --- a/apps/macos/Sources/Clawdis/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdis/GatewayConnection.swift @@ -40,7 +40,7 @@ struct GatewayAgentInvocation: Sendable { actor GatewayConnection { static let shared = GatewayConnection() - typealias Config = (url: URL, token: String?) + typealias Config = (url: URL, token: String?, password: String?) enum Method: String, Sendable { case agent @@ -83,6 +83,7 @@ actor GatewayConnection { private var client: GatewayChannelActor? private var configuredURL: URL? private var configuredToken: String? + private var configuredPassword: String? private var subscribers: [UUID: AsyncStream.Continuation] = [:] private var lastSnapshot: HelloOk? @@ -103,7 +104,7 @@ actor GatewayConnection { timeoutMs: Double? = nil) async throws -> Data { let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token) + await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) guard let client else { throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) } @@ -149,7 +150,7 @@ actor GatewayConnection { try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) do { let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token) + await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) guard let client = self.client else { throw NSError( domain: "Gateway", @@ -209,7 +210,7 @@ actor GatewayConnection { /// Ensure the underlying socket is configured (and replaced if config changed). func refresh() async throws { let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token) + await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) } func shutdown() async { @@ -264,8 +265,8 @@ actor GatewayConnection { } } - private func configure(url: URL, token: String?) async { - if self.client != nil, self.configuredURL == url, self.configuredToken == token { + private func configure(url: URL, token: String?, password: String?) async { + if self.client != nil, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password { return } if let client { @@ -275,12 +276,14 @@ actor GatewayConnection { self.client = GatewayChannelActor( url: url, token: token, + password: password, session: self.sessionBox, pushHandler: { [weak self] push in await self?.handle(push: push) }) self.configuredURL = url self.configuredToken = token + self.configuredPassword = password } private func handle(push: GatewayPush) { diff --git a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift index b0eec702a..079fdc8e7 100644 --- a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift @@ -2,7 +2,7 @@ import Foundation import OSLog enum GatewayEndpointState: Sendable, Equatable { - case ready(mode: AppState.ConnectionMode, url: URL, token: String?) + case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?) case unavailable(mode: AppState.ConnectionMode, reason: String) } @@ -17,18 +17,44 @@ actor GatewayEndpointStore { struct Deps: Sendable { let mode: @Sendable () async -> AppState.ConnectionMode let token: @Sendable () -> String? + let password: @Sendable () -> String? let localPort: @Sendable () -> Int let remotePortIfRunning: @Sendable () async -> UInt16? let ensureRemoteTunnel: @Sendable () async throws -> UInt16 static let live = Deps( mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, - token: { - // First check env var, fallback to config file - if let envToken = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"], !envToken.isEmpty { - return envToken + token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] }, + password: { + // First check environment variable + let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PASSWORD"] ?? "" + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed } - return ClawdisConfigFile.gatewayPassword() + // Then check config file based on connection mode + let root = ClawdisConfigFile.loadDict() + // Check gateway.auth.password (for local gateway auth) + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let password = auth["password"] as? String + { + let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) + if !pw.isEmpty { + return pw + } + } + // Check gateway.remote.password (for remote gateway auth) + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let password = remote["password"] as? String + { + let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) + if !pw.isEmpty { + return pw + } + } + return nil }, localPort: { GatewayEnvironment.gatewayPort() }, remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, @@ -53,9 +79,11 @@ actor GatewayEndpointStore { } let port = deps.localPort() + let token = deps.token() + let password = deps.password() switch initialMode { case .local: - self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: deps.token()) + self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password) case .remote: self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel") case .unconfigured: @@ -83,17 +111,18 @@ actor GatewayEndpointStore { func setMode(_ mode: AppState.ConnectionMode) async { let token = self.deps.token() + let password = self.deps.password() switch mode { case .local: let port = self.deps.localPort() - self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token)) + self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password)) case .remote: let port = await self.deps.remotePortIfRunning() guard let port else { self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")) return } - self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token)) + self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token, password: password)) case .unconfigured: self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) } @@ -116,8 +145,8 @@ actor GatewayEndpointStore { func requireConfig() async throws -> GatewayConnection.Config { await self.refresh() switch self.state { - case let .ready(_, url, token): - return (url, token) + case let .ready(_, url, token, password): + return (url, token, password) case let .unavailable(mode, reason): guard mode == .remote else { throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason]) @@ -128,9 +157,10 @@ actor GatewayEndpointStore { do { let forwarded = try await self.deps.ensureRemoteTunnel() let token = self.deps.token() + let password = self.deps.password() let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")! - self.setState(.ready(mode: .remote, url: url, token: token)) - return (url, token) + self.setState(.ready(mode: .remote, url: url, token: token, password: password)) + return (url, token, password) } catch { let msg = "\(reason) (\(error.localizedDescription))" self.setState(.unavailable(mode: .remote, reason: msg)) @@ -150,7 +180,7 @@ actor GatewayEndpointStore { continuation.yield(next) } switch next { - case let .ready(mode, url, _): + case let .ready(mode, url, _, _): let modeDesc = String(describing: mode) let urlDesc = url.absoluteString self.logger diff --git a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift index a5ee815f0..9cc01ffef 100644 --- a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift @@ -64,6 +64,7 @@ enum GatewayLaunchAgentManager { .joined(separator: ":") let bind = self.preferredGatewayBind() ?? "loopback" let token = self.preferredGatewayToken() + let password = self.preferredGatewayPassword() var envEntries = """ PATH \(preferredPath) @@ -76,6 +77,12 @@ enum GatewayLaunchAgentManager { \(token) """ } + if let password { + envEntries += """ + CLAWDIS_GATEWAY_PASSWORD + \(password) + """ + } let plist = """ @@ -146,6 +153,24 @@ enum GatewayLaunchAgentManager { return trimmed.isEmpty ? nil : trimmed } + private static func preferredGatewayPassword() -> String? { + // First check environment variable + let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PASSWORD"] ?? "" + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + // Then check config file (gateway.auth.password) + let root = ClawdisConfigFile.loadDict() + 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 struct LaunchctlResult { let status: Int32 let output: String diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 5ac5a37af..72b0abcfa 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -164,14 +164,23 @@ struct MenuContent: View { } private func saveBrowserControlEnabled(_ enabled: Bool) async { + let (success, _) = await MenuContent.buildAndSaveBrowserEnabled(enabled) + + if !success { + await self.loadBrowserControlEnabled() + } + } + + private nonisolated static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool,()) { var root = await ConfigStore.load() var browser = root["browser"] as? [String: Any] ?? [:] browser["enabled"] = enabled root["browser"] = browser do { try await ConfigStore.save(root) + return (true, ()) } catch { - await self.loadBrowserControlEnabled() + return (false, ()) } } diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift b/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift index 8f02adc38..4ff9f8fa1 100644 --- a/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift +++ b/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift @@ -75,6 +75,15 @@ extension OnboardingView { @discardableResult func saveAgentWorkspace(_ workspace: String?) async -> Bool { + let (success, errorMessage) = await OnboardingView.buildAndSaveWorkspace(workspace) + + if let errorMessage { + self.workspaceStatus = errorMessage + } + return success + } + + private nonisolated static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) { var root = await ConfigStore.load() var agent = root["agent"] as? [String: Any] ?? [:] let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -90,10 +99,10 @@ extension OnboardingView { } do { try await ConfigStore.save(root) - return true - } catch { - self.workspaceStatus = "Failed to save config: \(error.localizedDescription)" - return false + return (true, nil) + } catch let error { + let errorMessage = "Failed to save config: \(error.localizedDescription)" + return (false, errorMessage) } } } diff --git a/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift b/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift index f8cabf9c7..2015e221a 100644 --- a/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift +++ b/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift @@ -280,28 +280,56 @@ struct TailscaleIntegrationSection: View { return } + let (success, errorMessage) = await TailscaleIntegrationSection.buildAndSaveTailscaleConfig( + tailscaleMode: self.tailscaleMode, + requireCredentialsForServe: self.requireCredentialsForServe, + password: trimmedPassword, + connectionMode: self.connectionMode, + isPaused: self.isPaused + ) + + if !success, let errorMessage { + self.statusMessage = errorMessage + return + } + + if self.connectionMode == .local, !self.isPaused { + self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restarting gateway…" + } else { + self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restart the gateway to apply." + } + self.restartGatewayIfNeeded() + } + + private nonisolated static func buildAndSaveTailscaleConfig( + tailscaleMode: GatewayTailscaleMode, + requireCredentialsForServe: Bool, + password: String, + connectionMode: AppState.ConnectionMode, + isPaused: Bool + ) async -> (Bool, String?) { var root = await ConfigStore.load() var gateway = root["gateway"] as? [String: Any] ?? [:] var tailscale = gateway["tailscale"] as? [String: Any] ?? [:] - tailscale["mode"] = self.tailscaleMode.rawValue + tailscale["mode"] = tailscaleMode.rawValue gateway["tailscale"] = tailscale - if self.tailscaleMode != .off { + if tailscaleMode != .off { gateway["bind"] = "loopback" } - if self.tailscaleMode == .off { + if tailscaleMode == .off { gateway.removeValue(forKey: "auth") } else { var auth = gateway["auth"] as? [String: Any] ?? [:] - if self.tailscaleMode == .serve, !self.requireCredentialsForServe { + if tailscaleMode == .serve, !requireCredentialsForServe { auth["allowTailscale"] = true auth.removeValue(forKey: "mode") auth.removeValue(forKey: "password") } else { auth["allowTailscale"] = false auth["mode"] = "password" - auth["password"] = trimmedPassword + auth["password"] = password } if auth.isEmpty { @@ -319,17 +347,10 @@ struct TailscaleIntegrationSection: View { do { try await ConfigStore.save(root) - } catch { - self.statusMessage = error.localizedDescription - return + return (true, nil) + } catch let error { + return (false, error.localizedDescription) } - - if self.connectionMode == .local, !self.isPaused { - self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restarting gateway…" - } else { - self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restart the gateway to apply." - } - self.restartGatewayIfNeeded() } private func restartGatewayIfNeeded() { diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index fd929f88d..621a797bf 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -13cc362f2bc44e2a05a6da5e5ba66ea602755f18ed82b18cf244c8044aa84c36 +13cc362f2bc44e2a05a6da5e5ba66ea602755f18ed82b18cf244c8044aa84c36 \ No newline at end of file diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 4fc92e303..e17f8203f 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -46,7 +46,7 @@ export async function callGateway( (typeof opts.password === "string" && opts.password.trim().length > 0 ? opts.password.trim() : undefined) || - process.env.CLAWDIS_GATEWAY_PASSWORD || + (process.env.CLAWDIS_GATEWAY_PASSWORD?.trim()) || (typeof remote?.password === "string" && remote.password.trim().length > 0 ? remote.password.trim() : undefined); From 58d32d4542acfba2569144903192b44736742fe4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:50:09 +0000 Subject: [PATCH 03/44] docs: expand FAQ with skills, Tailscale, troubleshooting - How to add/reload skills (/reset) - Tailscale for multi-machine setups - Using Codex to debug - Handling supervised processes on Linux - Clean uninstall steps --- docs/faq.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/docs/faq.md b/docs/faq.md index 74e129c53..815c27198 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -213,6 +213,29 @@ One WhatsApp account = one phone number = one gateway connection. For a second n --- +## Skills & Tools + +### How do I add new skills? + +Skills are auto-discovered from your workspace's `skills/` folder. After adding new skills: + +1. Send `/reset` (or `/new`) in chat to start a new session +2. The new skills will be available + +No gateway restart needed! + +### How do I run commands on other machines? + +Use **Tailscale** to create a secure network between your machines: + +1. Install Tailscale on all machines +2. Each gets a stable IP (like `100.x.x.x`) +3. SSH just works: `ssh user@100.x.x.x "command"` + +For deeper integration, look into **Clawdis nodes** — pair remote machines with your gateway for camera/screen/automation access. + +--- + ## Troubleshooting ### Build errors (TypeScript) @@ -246,6 +269,48 @@ Common issues: - Missing API keys in config - Invalid config syntax (remember it's JSON5, but still check for errors) +**Pro tip:** Use Codex to debug: +```bash +cd ~/path/to/clawdis +codex --full-auto "debug why clawdis gateway won't start" +``` + +### Processes keep restarting after I kill them (Linux) + +Something is supervising them. Check: + +```bash +# systemd? +systemctl list-units | grep -i clawdis +sudo systemctl stop clawdis + +# pm2? +pm2 list +pm2 delete all +``` + +Stop the supervisor first, then the processes. + +### Clean uninstall (start fresh) + +```bash +# Stop processes +pkill -f "clawdis" + +# If using systemd +sudo systemctl stop clawdis +sudo systemctl disable clawdis + +# Remove data +rm -rf ~/.clawdis + +# Remove repo and re-clone +rm -rf ~/clawdis +git clone https://github.com/steipete/clawdis.git +cd clawdis && pnpm install && pnpm build +pnpm clawdis onboard +``` + --- ## Chat Commands From 0766c5e3cbde4b7105a7c39ca7c07521440725d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 12:59:47 +0100 Subject: [PATCH 04/44] refactor: move whatsapp allowFrom config --- CHANGELOG.md | 3 +- README.md | 4 +- docs/agent.md | 2 +- docs/clawd.md | 8 ++- docs/configuration.md | 10 +-- docs/doctor.md | 36 ++++++++++ docs/group-messages.md | 4 +- docs/groups.md | 2 +- docs/health.md | 2 +- docs/index.md | 8 +-- docs/security.md | 2 +- docs/telegram.md | 2 +- docs/troubleshooting.md | 4 +- docs/whatsapp.md | 8 +-- src/auto-reply/reply.directive.test.ts | 14 ++-- src/auto-reply/reply.triggers.test.ts | 10 +-- src/auto-reply/reply.ts | 40 +++++------ src/cli/program.ts | 16 +++++ src/commands/agent.ts | 4 +- src/commands/doctor.test.ts | 96 ++++++++++++++++++++++++++ src/commands/doctor.ts | 89 +++++++++++++++++++++++- src/commands/onboard-providers.ts | 16 ++--- src/config/config.test.ts | 34 +++++++++ src/config/config.ts | 70 ++++++++++++++++++- src/cron/isolated-agent.ts | 2 +- src/gateway/server.test.ts | 5 +- src/gateway/server.ts | 2 +- src/imessage/monitor.ts | 3 +- src/infra/heartbeat-runner.test.ts | 6 +- src/infra/heartbeat-runner.ts | 4 +- src/infra/provider-summary.ts | 4 +- src/signal/monitor.ts | 3 +- src/utils.ts | 2 +- src/web/auto-reply.test.ts | 18 ++--- src/web/auto-reply.ts | 8 +-- src/web/inbound.media.test.ts | 2 +- src/web/inbound.ts | 2 +- src/web/monitor-inbox.test.ts | 22 +++--- src/web/test-helpers.ts | 2 +- 39 files changed, 452 insertions(+), 117 deletions(-) create mode 100644 docs/doctor.md create mode 100644 src/commands/doctor.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 067ff1fb6..58c4cb842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Sessions: group keys now use `surface:group:` / `surface:channel:`; legacy `group:*` keys migrate on next message; `groupdm` keys are no longer recognized. - Discord: remove legacy `discord.allowFrom`, `discord.guildAllowFrom`, and `discord.requireMention`; use `discord.dm` + `discord.guilds`. - Providers: Discord/Telegram no longer auto-start from env tokens alone; add `discord: { enabled: true }` / `telegram: { enabled: true }` to your config when using `DISCORD_BOT_TOKEN` / `TELEGRAM_BOT_TOKEN`. +- Config: remove `routing.allowFrom`; use `whatsapp.allowFrom` instead (run `clawdis doctor` to migrate). ### Features - Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech. @@ -61,7 +62,7 @@ - CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup). - CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps. - CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard. -- CLI onboarding: always prompt for WhatsApp `routing.allowFrom` and print (optionally open) the Control UI URL when done. +- CLI onboarding: always prompt for WhatsApp `whatsapp.allowFrom` and print (optionally open) the Control UI URL when done. - CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode). - macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states. - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b diff --git a/README.md b/README.md index 538adfb2d..102bb644c 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ Minimal `~/.clawdis/clawdis.json`: ```json5 { - routing: { + whatsapp: { allowFrom: ["+1234567890"] } } @@ -166,7 +166,7 @@ Minimal `~/.clawdis/clawdis.json`: ### WhatsApp - Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`). -- Allowlist who can talk to the assistant via `routing.allowFrom`. +- Allowlist who can talk to the assistant via `whatsapp.allowFrom`. ### Telegram diff --git a/docs/agent.md b/docs/agent.md index 60ce61c3f..01cb830c2 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -76,7 +76,7 @@ Incoming user messages are queued while the agent is streaming. The queue is che At minimum, set: - `agent.workspace` -- `routing.allowFrom` (strongly recommended) +- `whatsapp.allowFrom` (strongly recommended) --- diff --git a/docs/clawd.md b/docs/clawd.md index 4d87403bf..1c43847d0 100644 --- a/docs/clawd.md +++ b/docs/clawd.md @@ -17,7 +17,7 @@ You’re putting an agent in a position to: - send messages back out via WhatsApp/Telegram/Discord Start conservative: -- Always set `routing.allowFrom` (never run open-to-the-world on your personal Mac). +- Always set `whatsapp.allowFrom` (never run open-to-the-world on your personal Mac). - Use a dedicated WhatsApp number for the assistant. - Keep heartbeats disabled until you trust the setup (omit `agent.heartbeat` or set `agent.heartbeat.every: "0m"`). @@ -74,7 +74,7 @@ clawdis gateway --port 18789 ```json5 { - routing: { + whatsapp: { allowFrom: ["+15555550123"] } } @@ -124,8 +124,10 @@ Example: // Start with 0; enable later. heartbeat: { every: "0m" } }, + whatsapp: { + allowFrom: ["+15555550123"] + }, routing: { - allowFrom: ["+15555550123"], groupChat: { requireMention: true, mentionPatterns: ["@clawd", "clawd"] diff --git a/docs/configuration.md b/docs/configuration.md index 0128706b4..38bee9cbf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,7 +9,7 @@ read_when: CLAWDIS reads an optional **JSON5** config from `~/.clawdis/clawdis.json` (comments + trailing commas allowed). If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to: -- restrict who can trigger the bot (`routing.allowFrom`) +- restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.) - tune group mention behavior (`routing.groupChat`) - customize message prefixes (`messages`) - set the agent’s workspace (`agent.workspace`) @@ -21,7 +21,7 @@ If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per- ```json5 { agent: { workspace: "~/clawd" }, - routing: { allowFrom: ["+15555550123"] } + whatsapp: { allowFrom: ["+15555550123"] } } ``` @@ -76,13 +76,13 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`). } ``` -### `routing.allowFrom` +### `whatsapp.allowFrom` -Allowlist of E.164 phone numbers that may trigger auto-replies. +Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies. ```json5 { - routing: { allowFrom: ["+15555550123", "+447700900123"] } + whatsapp: { allowFrom: ["+15555550123", "+447700900123"] } } ``` diff --git a/docs/doctor.md b/docs/doctor.md new file mode 100644 index 000000000..bd81cd1fe --- /dev/null +++ b/docs/doctor.md @@ -0,0 +1,36 @@ +--- +summary: "Doctor command: health checks, config migrations, and repair steps" +read_when: + - Adding or modifying doctor migrations + - Introducing breaking config changes +--- +# Doctor + +`clawdis doctor` is the repair + migration tool for Clawdis. It runs a quick health check, audits skills, and can migrate deprecated config entries to the new schema. + +## What it does +- Runs a health check and offers to restart the gateway if it looks unhealthy. +- Prints a skills status summary (eligible/missing/blocked). +- Detects deprecated config keys and offers to migrate them. + +## Legacy config migrations +When the config contains deprecated keys, other commands will refuse to run and ask you to run `clawdis doctor`. +Doctor will: +- Explain which legacy keys were found. +- Show the migration it applied. +- Rewrite `~/.clawdis/clawdis.json` with the updated schema. + +Current migrations: +- `routing.allowFrom` → `whatsapp.allowFrom` + +## Usage + +```bash +clawdis doctor +``` + +If you want to review changes before writing, open the config file first: + +```bash +cat ~/.clawdis/clawdis.json +``` diff --git a/docs/group-messages.md b/docs/group-messages.md index 94012b168..e8fb355d0 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -9,7 +9,7 @@ Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that ## What’s implemented (2025-12-03) - Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Activation is controlled per group (command or UI), not via config. -- Group allowlist bypass: we still enforce `routing.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies. +- Group allowlist bypass: we still enforce `whatsapp.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies. - Per-group sessions: session keys look like `whatsapp:group:` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads. - Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. - Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking. @@ -45,7 +45,7 @@ Use the group chat command: - `/activation mention` - `/activation always` -Only the owner number (from `routing.allowFrom`, defaulting to the bot’s own E.164 when unset) can change this. `/status` in the group shows the current activation mode. +Only the owner number (from `whatsapp.allowFrom`, defaulting to the bot’s own E.164 when unset) can change this. `/status` in the group shows the current activation mode. ## How to use 1) Add Clawd UK (`+447700900123`) to the group. diff --git a/docs/groups.md b/docs/groups.md index febde1f18..b24b27e39 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -40,7 +40,7 @@ Group owners can toggle per-group activation: - `/activation mention` - `/activation always` -Owner is determined by `routing.allowFrom` (or the bot’s default identity when unset). +Owner is determined by `whatsapp.allowFrom` (or the bot’s self E.164 when unset). Other surfaces currently ignore `/activation`. ## Context fields Group inbound payloads set: diff --git a/docs/health.md b/docs/health.md index 5d2ec90dd..316ac6fab 100644 --- a/docs/health.md +++ b/docs/health.md @@ -22,7 +22,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing. ## When something fails - `logged out` or status 409–515 → relink with `clawdis logout` then `clawdis login`. - Gateway unreachable → start it: `clawdis gateway --port 18789` (use `--force` if the port is busy). -- No inbound messages → confirm linked phone is online and the sender is allowed (`routing.allowFrom`); for group chats, ensure mention rules match (`routing.groupChat`). +- No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure mention rules match (`routing.groupChat`). ## Dedicated "health" command `clawdis health --json` asks the running Gateway for its health snapshot (no direct Baileys socket from the CLI). It reports linked creds, auth age, Baileys connect result/status code, session-store summary, and a probe duration. It exits non-zero if the Gateway is unreachable or the probe fails/timeouts. Use `--timeout ` to override the 10s default. diff --git a/docs/index.md b/docs/index.md index 3f454b5be..dd9316fb5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -100,16 +100,14 @@ clawdis send --to +15555550123 --message "Hello from CLAWDIS" Config lives at `~/.clawdis/clawdis.json`. - If you **do nothing**, CLAWDIS uses the bundled Pi binary in RPC mode with per-sender sessions. -- If you want to lock it down, start with `routing.allowFrom` and (for groups) mention rules. +- If you want to lock it down, start with `whatsapp.allowFrom` and (for groups) mention rules. Example: ```json5 { - routing: { - allowFrom: ["+15555550123"], - groupChat: { requireMention: true, mentionPatterns: ["@clawd"] } - } + whatsapp: { allowFrom: ["+15555550123"] }, + routing: { groupChat: { requireMention: true, mentionPatterns: ["@clawd"] } } } ``` diff --git a/docs/security.md b/docs/security.md index c5c261bbb..32d76adae 100644 --- a/docs/security.md +++ b/docs/security.md @@ -42,7 +42,7 @@ This is social engineering 101. Create distrust, encourage snooping. ```json { - "routing": { + "whatsapp": { "allowFrom": ["+15555550123"] } } diff --git a/docs/telegram.md b/docs/telegram.md index 02f63d5b0..797783f72 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -25,7 +25,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup - If you need a different public port/host, set `telegram.webhookUrl` to the externally reachable URL and use a reverse proxy to forward to `:8787`. 4) Direct chats: user sends the first message; all subsequent turns land in the shared `main` session (default, no extra config). 5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:` and require mention/command to trigger replies. -6) Optional allowlist: reuse `routing.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`). +6) Optional allowlist: use `telegram.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`). ## Capabilities & limits (Bot API) - Sees only messages sent after it’s added to a chat; no pre-history access. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 058d6e726..6588c21c9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -22,9 +22,9 @@ The agent was interrupted mid-response. ### Messages Not Triggering -**Check 1:** Is the sender in `routing.allowFrom`? +**Check 1:** Is the sender in `whatsapp.allowFrom`? ```bash -cat ~/.clawdis/clawdis.json | jq '.routing.allowFrom' +cat ~/.clawdis/clawdis.json | jq '.whatsapp.allowFrom' ``` **Check 2:** For group chats, is mention required? diff --git a/docs/whatsapp.md b/docs/whatsapp.md index ee4a32837..7f854551f 100644 --- a/docs/whatsapp.md +++ b/docs/whatsapp.md @@ -31,8 +31,8 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session. - Inbox listeners are detached on shutdown to avoid accumulating event handlers in tests/restarts. - Status/broadcast chats are ignored. - Direct chats use E.164; groups use group JID. -- **Allowlist**: `routing.allowFrom` enforced for direct chats only. - - If `routing.allowFrom` is empty, default allowlist = self number (self-chat mode). +- **Allowlist**: `whatsapp.allowFrom` enforced for direct chats only. + - If `whatsapp.allowFrom` is empty, default allowlist = self number (self-chat mode). - **Self-chat mode**: avoids auto read receipts and ignores mention JIDs. - Read receipts sent for non-self-chat DMs. @@ -57,7 +57,7 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session. - `mention` (default): requires @mention or regex match. - `always`: always triggers. - `/activation mention|always` is owner-only. -- Owner = `routing.allowFrom` (or self E.164 if unset). +- Owner = `whatsapp.allowFrom` (or self E.164 if unset). - **History injection**: - Recent messages (default 50) inserted under: `[Chat messages since your last reply - for context]` @@ -98,7 +98,7 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session. - Logged-out => stop and require re-link. ## Config quick map -- `routing.allowFrom` (DM allowlist). +- `whatsapp.allowFrom` (DM allowlist). - `routing.groupChat.mentionPatterns` - `routing.groupChat.historyLimit` - `messages.messagePrefix` (inbound prefix) diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 98f65c91a..d5c339530 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -118,7 +118,7 @@ describe("directive parsing", () => { model: "anthropic/claude-opus-4-5", workspace: path.join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { store: path.join(home, "sessions.json") }, @@ -168,7 +168,7 @@ describe("directive parsing", () => { model: "anthropic/claude-opus-4-5", workspace: path.join(home, "clawd"), }, - routing: { allowFrom: ["*"] }, + whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, }, ); @@ -195,7 +195,7 @@ describe("directive parsing", () => { model: "anthropic/claude-opus-4-5", workspace: path.join(home, "clawd"), }, - routing: { allowFrom: ["*"] }, + whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, }, ); @@ -208,7 +208,7 @@ describe("directive parsing", () => { model: "anthropic/claude-opus-4-5", workspace: path.join(home, "clawd"), }, - routing: { allowFrom: ["*"] }, + whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, }, ); @@ -264,7 +264,7 @@ describe("directive parsing", () => { model: "anthropic/claude-opus-4-5", workspace: path.join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { store: storePath }, @@ -325,7 +325,7 @@ describe("directive parsing", () => { model: "anthropic/claude-opus-4-5", workspace: path.join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { store: storePath }, @@ -506,7 +506,7 @@ describe("directive parsing", () => { workspace: path.join(home, "clawd"), allowedModels: ["openai/gpt-4.1-mini"], }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { store: storePath }, diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 3d84067d7..8b8242208 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -42,7 +42,7 @@ function makeCfg(home: string) { model: "anthropic/claude-opus-4-5", workspace: join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { store: join(home, "sessions.json") }, @@ -283,8 +283,10 @@ describe("trigger handling", () => { model: "anthropic/claude-opus-4-5", workspace: join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], + }, + routing: { groupChat: { requireMention: false }, }, session: { store: join(home, "sessions.json") }, @@ -324,7 +326,7 @@ describe("trigger handling", () => { model: "anthropic/claude-opus-4-5", workspace: join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { @@ -363,7 +365,7 @@ describe("trigger handling", () => { model: "anthropic/claude-opus-4-5", workspace: join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 58e9eb914..b3c380a38 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -841,14 +841,20 @@ export async function getReplyFromConfig( const perMessageQueueMode = hasQueueDirective && !inlineQueueReset ? inlineQueueMode : undefined; - // Optional allowlist by origin number (E.164 without whatsapp: prefix) - const configuredAllowFrom = cfg.routing?.allowFrom; + const surface = (ctx.Surface ?? "").trim().toLowerCase(); + const isWhatsAppSurface = + surface === "whatsapp" || + (ctx.From ?? "").startsWith("whatsapp:") || + (ctx.To ?? "").startsWith("whatsapp:"); + + // WhatsApp owner allowlist (E.164 without whatsapp: prefix); used for group activation only. + const configuredAllowFrom = isWhatsAppSurface + ? cfg.whatsapp?.allowFrom + : undefined; const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); - const isSamePhone = from && to && from === to; - // If no config is present, default to self-only DM access. const defaultAllowFrom = - (!configuredAllowFrom || configuredAllowFrom.length === 0) && to + isWhatsAppSurface && (!configuredAllowFrom || configuredAllowFrom.length === 0) && to ? [to] : undefined; const allowFrom = @@ -862,10 +868,12 @@ export async function getReplyFromConfig( : rawBodyNormalized; const activationCommand = parseActivationCommand(commandBodyNormalized); const senderE164 = normalizeE164(ctx.SenderE164 ?? ""); - const ownerCandidates = (allowFrom ?? []).filter( - (entry) => entry && entry !== "*", - ); - if (ownerCandidates.length === 0 && to) ownerCandidates.push(to); + const ownerCandidates = isWhatsAppSurface + ? (allowFrom ?? []).filter((entry) => entry && entry !== "*") + : []; + if (isWhatsAppSurface && ownerCandidates.length === 0 && to) { + ownerCandidates.push(to); + } const ownerList = ownerCandidates .map((entry) => normalizeE164(entry)) .filter((entry): entry is string => Boolean(entry)); @@ -876,20 +884,6 @@ export async function getReplyFromConfig( abortedLastRun = ABORT_MEMORY.get(abortKey) ?? false; } - // Same-phone mode (self-messaging) is always allowed - if (isSamePhone) { - logVerbose(`Allowing same-phone mode: from === to (${from})`); - } else if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) { - // Support "*" as wildcard to allow all senders - if (!allowFrom.includes("*") && !allowFrom.includes(from)) { - logVerbose( - `Skipping auto-reply: sender ${from || ""} not in allowFrom list`, - ); - cleanupTyping(); - return undefined; - } - } - if (activationCommand.hasCommand) { if (!isGroup) { cleanupTyping(); diff --git a/src/cli/program.ts b/src/cli/program.ts index b53f4137d..d5e9d6a52 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -14,6 +14,7 @@ import { danger, setVerbose } from "../globals.js"; import { loginWeb, logoutWeb } from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; import { VERSION } from "../version.js"; +import { readConfigFileSnapshot } from "../config/config.js"; import { registerBrowserCli } from "./browser-cli.js"; import { registerCanvasCli } from "./canvas-cli.js"; import { registerCronCli } from "./cron-cli.js"; @@ -68,6 +69,21 @@ export function buildProgram() { } program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`); + + program.hook("preAction", async (_thisCommand, actionCommand) => { + if (actionCommand.name() === "doctor") return; + const snapshot = await readConfigFileSnapshot(); + if (snapshot.legacyIssues.length === 0) return; + const issues = snapshot.legacyIssues + .map((issue) => `- ${issue.path}: ${issue.message}`) + .join("\n"); + defaultRuntime.error( + danger( + `Legacy config entries detected. Ask your agent to run \"clawdis doctor\" to migrate.\n${issues}`, + ), + ); + process.exit(1); + }); const examples = [ [ "clawdis login --verbose", diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 64daf9a70..d62398f27 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -158,7 +158,7 @@ export async function agentCommand( }); const workspaceDir = workspace.dir; - const allowFrom = (cfg.routing?.allowFrom ?? []) + const allowFrom = (cfg.whatsapp?.allowFrom ?? []) .map((val) => normalizeE164(val)) .filter((val) => val.length > 1); @@ -451,7 +451,7 @@ export async function agentCommand( if (deliver) { if (deliveryProvider === "whatsapp" && !whatsappTarget) { const err = new Error( - "Delivering to WhatsApp requires --to or routing.allowFrom[0]", + "Delivering to WhatsApp requires --to or whatsapp.allowFrom[0]", ); if (!bestEffortDeliver) throw err; logDeliveryError(err); diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts new file mode 100644 index 000000000..a988a370d --- /dev/null +++ b/src/commands/doctor.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; + +const readConfigFileSnapshot = vi.fn(); +const writeConfigFile = vi.fn().mockResolvedValue(undefined); +const validateConfigObject = vi.fn((raw: unknown) => ({ + ok: true as const, + config: raw as Record, +})); + +vi.mock("@clack/prompts", () => ({ + confirm: vi.fn().mockResolvedValue(true), + intro: vi.fn(), + note: vi.fn(), + outro: vi.fn(), +})); + +vi.mock("../agents/skills-status.js", () => ({ + buildWorkspaceSkillStatus: () => ({ skills: [] }), +})); + +vi.mock("../config/config.js", () => ({ + CONFIG_PATH_CLAWDIS: "/tmp/clawdis.json", + readConfigFileSnapshot, + writeConfigFile, + validateConfigObject, +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime: { + log: () => {}, + error: () => {}, + exit: () => { + throw new Error("exit"); + }, + }, +})); + +vi.mock("../utils.js", () => ({ + resolveUserPath: (value: string) => value, + sleep: vi.fn(), +})); + +vi.mock("./health.js", () => ({ + healthCommand: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./onboard-helpers.js", () => ({ + applyWizardMetadata: (cfg: Record) => cfg, + DEFAULT_WORKSPACE: "/tmp", + guardCancel: (value: unknown) => value, + printWizardHeader: vi.fn(), +})); + +describe("doctor", () => { + it("migrates routing.allowFrom to whatsapp.allowFrom", async () => { + readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/clawdis.json", + exists: true, + raw: "{}", + parsed: { routing: { allowFrom: ["+15555550123"] } }, + valid: false, + config: {}, + issues: [ + { + path: "routing.allowFrom", + message: "legacy", + }, + ], + legacyIssues: [ + { + path: "routing.allowFrom", + message: "legacy", + }, + ], + }); + + const { doctorCommand } = await import("./doctor.js"); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await doctorCommand(runtime); + + expect(writeConfigFile).toHaveBeenCalledTimes(1); + const written = writeConfigFile.mock.calls[0]?.[0] as Record< + string, + unknown + >; + expect((written.whatsapp as Record)?.allowFrom).toEqual([ + "+15555550123", + ]); + expect(written.routing).toBeUndefined(); + }); +}); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 8e52b0d24..da8072a53 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -5,6 +5,7 @@ import type { ClawdisConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDIS, readConfigFileSnapshot, + validateConfigObject, writeConfigFile, } from "../config/config.js"; import { resolveGatewayService } from "../daemon/service.js"; @@ -19,6 +20,65 @@ import { printWizardHeader, } from "./onboard-helpers.js"; +type LegacyMigration = { + id: string; + describe: string; + apply: (raw: Record, changes: string[]) => void; +}; + +const LEGACY_MIGRATIONS: LegacyMigration[] = [ + // Legacy migration (2026-01-02, commit: TBD) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. + { + id: "routing.allowFrom->whatsapp.allowFrom", + describe: "Move routing.allowFrom to whatsapp.allowFrom", + apply: (raw, changes) => { + const routing = raw.routing; + if (!routing || typeof routing !== "object") return; + const allowFrom = (routing as Record).allowFrom; + if (allowFrom === undefined) return; + + const whatsapp = + raw.whatsapp && typeof raw.whatsapp === "object" + ? (raw.whatsapp as Record) + : {}; + + if (whatsapp.allowFrom === undefined) { + whatsapp.allowFrom = allowFrom; + changes.push("Moved routing.allowFrom → whatsapp.allowFrom."); + } else { + changes.push("Removed routing.allowFrom (whatsapp.allowFrom already set)."); + } + + delete (routing as Record).allowFrom; + if (Object.keys(routing as Record).length === 0) { + delete raw.routing; + } + raw.whatsapp = whatsapp; + }, + }, +]; + +function applyLegacyMigrations(raw: unknown): { + config: ClawdisConfig | null; + changes: string[]; +} { + if (!raw || typeof raw !== "object") return { config: null, changes: [] }; + const next = structuredClone(raw) as Record; + const changes: string[] = []; + for (const migration of LEGACY_MIGRATIONS) { + migration.apply(next, changes); + } + if (changes.length === 0) return { config: null, changes: [] }; + const validated = validateConfigObject(next); + if (!validated.ok) { + changes.push( + "Migration applied, but config still invalid; fix remaining issues manually.", + ); + return { config: null, changes }; + } + return { config: validated.config, changes }; +} + function resolveMode(cfg: ClawdisConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; } @@ -29,10 +89,37 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { const snapshot = await readConfigFileSnapshot(); let cfg: ClawdisConfig = snapshot.valid ? snapshot.config : {}; - if (snapshot.exists && !snapshot.valid) { + if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) { note("Config invalid; doctor will run with defaults.", "Config"); } + if (snapshot.legacyIssues.length > 0) { + note( + snapshot.legacyIssues + .map((issue) => `- ${issue.path}: ${issue.message}`) + .join("\n"), + "Legacy config keys detected", + ); + const migrate = guardCancel( + await confirm({ + message: "Migrate legacy config entries now?", + initialValue: true, + }), + runtime, + ); + if (migrate) { + const { config: migrated, changes } = applyLegacyMigrations( + snapshot.parsed, + ); + if (changes.length > 0) { + note(changes.join("\n"), "Doctor changes"); + } + if (migrated) { + cfg = migrated; + } + } + } + const workspaceDir = resolveUserPath( cfg.agent?.workspace ?? DEFAULT_WORKSPACE, ); diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index a120d66d9..af537ec06 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -64,11 +64,11 @@ function noteDiscordTokenHelp(): void { ); } -function setRoutingAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) { +function setWhatsAppAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) { return { ...cfg, - routing: { - ...cfg.routing, + whatsapp: { + ...cfg.whatsapp, allowFrom, }, }; @@ -78,13 +78,13 @@ async function promptWhatsAppAllowFrom( cfg: ClawdisConfig, runtime: RuntimeEnv, ): Promise { - const existingAllowFrom = cfg.routing?.allowFrom ?? []; + const existingAllowFrom = cfg.whatsapp?.allowFrom ?? []; const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; note( [ - "WhatsApp direct chats are gated by `routing.allowFrom`.", + "WhatsApp direct chats are gated by `whatsapp.allowFrom`.", 'Default (unset) = self-chat only; use "*" to allow anyone.', `Current: ${existingLabel}`, ].join("\n"), @@ -114,8 +114,8 @@ async function promptWhatsAppAllowFrom( ) as (typeof options)[number]["value"]; if (mode === "keep") return cfg; - if (mode === "self") return setRoutingAllowFrom(cfg, undefined); - if (mode === "any") return setRoutingAllowFrom(cfg, ["*"]); + if (mode === "self") return setWhatsAppAllowFrom(cfg, undefined); + if (mode === "any") return setWhatsAppAllowFrom(cfg, ["*"]); const allowRaw = guardCancel( await text({ @@ -148,7 +148,7 @@ async function promptWhatsAppAllowFrom( part === "*" ? "*" : normalizeE164(part), ); const unique = [...new Set(normalized.filter(Boolean))]; - return setRoutingAllowFrom(cfg, unique); + return setWhatsAppAllowFrom(cfg, unique); } export async function setupProviders( diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 06b774fe7..06e9e8377 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -488,3 +488,37 @@ describe("talk.voiceAliases", () => { expect(res.ok).toBe(false); }); }); + +describe("legacy config detection", () => { + it("rejects routing.allowFrom", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + routing: { allowFrom: ["+15555550123"] }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("routing.allowFrom"); + } + }); + + it("surfaces legacy issues in snapshot", async () => { + await withTempHome(async (home) => { + const configPath = path.join(home, ".clawdis", "clawdis.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ routing: { allowFrom: ["+15555550123"] } }), + "utf-8", + ); + + vi.resetModules(); + const { readConfigFileSnapshot } = await import("./config.js"); + const snap = await readConfigFileSnapshot(); + + expect(snap.valid).toBe(false); + expect(snap.legacyIssues.length).toBe(1); + expect(snap.legacyIssues[0]?.path).toBe("routing.allowFrom"); + }); + }); +}); diff --git a/src/config/config.ts b/src/config/config.ts index b4d1161c7..9c217fc95 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -58,6 +58,11 @@ export type WebConfig = { reconnect?: WebReconnectConfig; }; +export type WhatsAppConfig = { + /** Optional allowlist for WhatsApp direct chats (E.164). */ + allowFrom?: string[]; +}; + export type BrowserConfig = { enabled?: boolean; /** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */ @@ -260,7 +265,6 @@ export type GroupChatConfig = { }; export type RoutingConfig = { - allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) transcribeAudio?: { // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. command: string[]; @@ -525,6 +529,7 @@ export type ClawdisConfig = { messages?: MessagesConfig; session?: SessionConfig; web?: WebConfig; + whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; signal?: SignalConfig; @@ -693,7 +698,6 @@ const HeartbeatSchema = z const RoutingSchema = z .object({ - allowFrom: z.array(z.string()).optional(), groupChat: GroupChatSchema, transcribeAudio: TranscribeAudioSchema, queue: z @@ -909,6 +913,11 @@ const ClawdisSchema = z.object({ .optional(), }) .optional(), + whatsapp: z + .object({ + allowFrom: z.array(z.string()).optional(), + }) + .optional(), telegram: z .object({ enabled: z.boolean().optional(), @@ -1131,6 +1140,11 @@ export type ConfigValidationIssue = { message: string; }; +export type LegacyConfigIssue = { + path: string; + message: string; +}; + export type ConfigFileSnapshot = { path: string; exists: boolean; @@ -1139,8 +1153,42 @@ export type ConfigFileSnapshot = { valid: boolean; config: ClawdisConfig; issues: ConfigValidationIssue[]; + legacyIssues: LegacyConfigIssue[]; }; +type LegacyConfigRule = { + path: string[]; + message: string; +}; + +const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ + { + path: ["routing", "allowFrom"], + message: + "routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdis doctor` to migrate).", + }, +]; + +function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { + if (!raw || typeof raw !== "object") return []; + const root = raw as Record; + const issues: LegacyConfigIssue[] = []; + for (const rule of LEGACY_CONFIG_RULES) { + let cursor: unknown = root; + for (const key of rule.path) { + if (!cursor || typeof cursor !== "object") { + cursor = undefined; + break; + } + cursor = (cursor as Record)[key]; + } + if (cursor !== undefined) { + issues.push({ path: rule.path.join("."), message: rule.message }); + } + } + return issues; +} + function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -1199,6 +1247,16 @@ export function validateConfigObject( ): | { ok: true; config: ClawdisConfig } | { ok: false; issues: ConfigValidationIssue[] } { + const legacyIssues = findLegacyConfigIssues(raw); + if (legacyIssues.length > 0) { + return { + ok: false, + issues: legacyIssues.map((iss) => ({ + path: iss.path, + message: iss.message, + })), + }; + } const validated = ClawdisSchema.safeParse(raw); if (!validated.success) { return { @@ -1271,6 +1329,7 @@ export async function readConfigFileSnapshot(): Promise { const exists = fs.existsSync(configPath); if (!exists) { const config = applyTalkApiKey({}); + const legacyIssues: LegacyConfigIssue[] = []; return { path: configPath, exists: false, @@ -1279,6 +1338,7 @@ export async function readConfigFileSnapshot(): Promise { valid: true, config, issues: [], + legacyIssues, }; } @@ -1296,9 +1356,12 @@ export async function readConfigFileSnapshot(): Promise { issues: [ { path: "", message: `JSON5 parse failed: ${parsedRes.error}` }, ], + legacyIssues: [], }; } + const legacyIssues = findLegacyConfigIssues(parsedRes.parsed); + const validated = validateConfigObject(parsedRes.parsed); if (!validated.ok) { return { @@ -1309,6 +1372,7 @@ export async function readConfigFileSnapshot(): Promise { valid: false, config: {}, issues: validated.issues, + legacyIssues, }; } @@ -1320,6 +1384,7 @@ export async function readConfigFileSnapshot(): Promise { valid: true, config: applyTalkApiKey(validated.config), issues: [], + legacyIssues, }; } catch (err) { return { @@ -1330,6 +1395,7 @@ export async function readConfigFileSnapshot(): Promise { valid: false, config: {}, issues: [{ path: "", message: `read failed: ${String(err)}` }], + legacyIssues: [], }; } } diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index bd74efc9a..2ed808e91 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -103,7 +103,7 @@ function resolveDeliveryTarget( const sanitizedWhatsappTo = (() => { if (channel !== "whatsapp") return to; - const rawAllow = cfg.routing?.allowFrom ?? []; + const rawAllow = cfg.whatsapp?.allowFrom ?? []; if (rawAllow.includes("*")) return to; const allowFrom = rawAllow .map((val) => normalizeE164(val)) diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index cfac4af86..2c58d039f 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -163,6 +163,7 @@ vi.mock("../config/config.js", () => { valid: true, config: {}, issues: [], + legacyIssues: [], }; } try { @@ -176,6 +177,7 @@ vi.mock("../config/config.js", () => { valid: true, config: parsed, issues: [], + legacyIssues: [], }; } catch (err) { return { @@ -186,6 +188,7 @@ vi.mock("../config/config.js", () => { valid: false, config: {}, issues: [{ path: "", message: `read failed: ${String(err)}` }], + legacyIssues: [], }; } }; @@ -206,7 +209,7 @@ vi.mock("../config/config.js", () => { model: "anthropic/claude-opus-4-5", workspace: path.join(os.tmpdir(), "clawd-gateway-test"), }, - routing: { + whatsapp: { allowFrom: testAllowFrom, }, session: { mainKey: "main", store: testSessionStorePath }, diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 6cebf0577..979e04188 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -6641,7 +6641,7 @@ export async function startGatewayServer( if (explicit) return resolvedTo; const cfg = cfgForAgent ?? loadConfig(); - const rawAllow = cfg.routing?.allowFrom ?? []; + const rawAllow = cfg.whatsapp?.allowFrom ?? []; if (rawAllow.includes("*")) return resolvedTo; const allowFrom = rawAllow .map((val) => normalizeE164(val)) diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index 33e0ca95d..bbc0fcb36 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -61,8 +61,7 @@ function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv { function resolveAllowFrom(opts: MonitorIMessageOpts): string[] { const cfg = loadConfig(); - const raw = - opts.allowFrom ?? cfg.imessage?.allowFrom ?? cfg.routing?.allowFrom ?? []; + const raw = opts.allowFrom ?? cfg.imessage?.allowFrom ?? []; return raw.map((entry) => String(entry).trim()).filter(Boolean); } diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index 001b66319..c2927dd8f 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -94,7 +94,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { it("applies allowFrom fallback for WhatsApp targets", () => { const cfg: ClawdisConfig = { agent: { heartbeat: { target: "whatsapp", to: "+1999" } }, - routing: { allowFrom: ["+1555", "+1666"] }, + whatsapp: { allowFrom: ["+1555", "+1666"] }, }; const entry = { ...baseEntry, @@ -145,7 +145,7 @@ describe("runHeartbeatOnce", () => { agent: { heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, }, - routing: { allowFrom: ["*"] }, + whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, }; @@ -206,7 +206,7 @@ describe("runHeartbeatOnce", () => { agent: { heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, }, - routing: { allowFrom: ["*"] }, + whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, }; diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index b94d77f7b..822f537ee 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -235,7 +235,7 @@ export function resolveHeartbeatDeliveryTarget(params: { return { channel, to }; } - const rawAllow = cfg.routing?.allowFrom ?? []; + const rawAllow = cfg.whatsapp?.allowFrom ?? []; if (rawAllow.includes("*")) return { channel, to }; const allowFrom = rawAllow .map((val) => normalizeE164(val)) @@ -401,7 +401,7 @@ export async function runHeartbeatOnce(opts: { const startedAt = opts.deps?.nowMs?.() ?? Date.now(); const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg); const previousUpdatedAt = entry?.updatedAt; - const allowFrom = cfg.routing?.allowFrom ?? []; + const allowFrom = cfg.whatsapp?.allowFrom ?? []; const sender = resolveHeartbeatSender({ allowFrom, lastTo: entry?.lastTo, diff --git a/src/infra/provider-summary.ts b/src/infra/provider-summary.ts index 2645a7fb8..e471f0118 100644 --- a/src/infra/provider-summary.ts +++ b/src/infra/provider-summary.ts @@ -80,8 +80,8 @@ export async function buildProviderSummary( ); } - const allowFrom = effective.routing?.allowFrom?.length - ? effective.routing.allowFrom.map(normalizeE164).filter(Boolean) + const allowFrom = effective.whatsapp?.allowFrom?.length + ? effective.whatsapp.allowFrom.map(normalizeE164).filter(Boolean) : []; if (allowFrom.length) { lines.push(chalk.cyan(`AllowFrom: ${allowFrom.join(", ")}`)); diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 90763acd4..2c1e52902 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -92,8 +92,7 @@ function resolveAccount(opts: MonitorSignalOpts): string | undefined { function resolveAllowFrom(opts: MonitorSignalOpts): string[] { const cfg = loadConfig(); - const raw = - opts.allowFrom ?? cfg.signal?.allowFrom ?? cfg.routing?.allowFrom ?? []; + const raw = opts.allowFrom ?? cfg.signal?.allowFrom ?? []; return raw.map((entry) => String(entry).trim()).filter(Boolean); } diff --git a/src/utils.ts b/src/utils.ts index 6882209a7..737159f24 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -33,7 +33,7 @@ export function normalizeE164(number: string): string { /** * "Self-chat mode" heuristic (single phone): the gateway is logged in as the owner's own WhatsApp account, - * and `routing.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the + * and `whatsapp.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the * "bot" and the human are the same WhatsApp identity (e.g. auto read receipts, @mention JID triggers). */ export function isSelfChatMode( diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 05b217e7b..4cc2df39b 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -111,7 +111,7 @@ describe("partial reply gating", () => { const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" }); const mockConfig: ClawdisConfig = { - routing: { + whatsapp: { allowFrom: ["*"], }, }; @@ -158,7 +158,7 @@ describe("partial reply gating", () => { const replyResolver = vi.fn().mockResolvedValue(undefined); const mockConfig: ClawdisConfig = { - routing: { + whatsapp: { allowFrom: ["*"], }, session: { store: store.storePath, mainKey: "main" }, @@ -1097,9 +1097,11 @@ describe("web auto-reply", () => { const resolver = vi.fn().mockResolvedValue({ text: "ok" }); setLoadConfigMock(() => ({ - routing: { + whatsapp: { // Self-chat heuristic: allowFrom includes selfE164. allowFrom: ["+999"], + }, + routing: { groupChat: { requireMention: true, mentionPatterns: ["\\bclawd\\b"], @@ -1247,7 +1249,7 @@ describe("web auto-reply", () => { it("prefixes body with same-phone marker when from === to", async () => { // Enable messagePrefix for same-phone mode testing setLoadConfigMock(() => ({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { @@ -1372,7 +1374,7 @@ describe("web auto-reply", () => { it("applies responsePrefix to regular replies", async () => { setLoadConfigMock(() => ({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { @@ -1417,7 +1419,7 @@ describe("web auto-reply", () => { it("does not deliver HEARTBEAT_OK responses", async () => { setLoadConfigMock(() => ({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { @@ -1462,7 +1464,7 @@ describe("web auto-reply", () => { it("does not double-prefix if responsePrefix already present", async () => { setLoadConfigMock(() => ({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { @@ -1508,7 +1510,7 @@ describe("web auto-reply", () => { it("sends tool summaries immediately with responsePrefix", async () => { setLoadConfigMock(() => ({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 762cb8135..628254100 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -116,7 +116,7 @@ function buildMentionConfig(cfg: ReturnType): MentionConfig { } }) .filter((r): r is RegExp => Boolean(r)) ?? []; - return { mentionRegexes, allowFrom: cfg.routing?.allowFrom }; + return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom }; } function isBotMentioned( @@ -448,8 +448,8 @@ export function resolveHeartbeatRecipients( const sessionRecipients = getSessionRecipients(cfg); const allowFrom = - Array.isArray(cfg.routing?.allowFrom) && cfg.routing.allowFrom.length > 0 - ? cfg.routing.allowFrom.filter((v) => v !== "*").map(normalizeE164) + Array.isArray(cfg.whatsapp?.allowFrom) && cfg.whatsapp.allowFrom.length > 0 + ? cfg.whatsapp.allowFrom.filter((v) => v !== "*").map(normalizeE164) : []; const unique = (list: string[]) => [...new Set(list.filter(Boolean))]; @@ -918,7 +918,7 @@ export async function monitorWebProvider( // Build message prefix: explicit config > default based on allowFrom let messagePrefix = cfg.messages?.messagePrefix; if (messagePrefix === undefined) { - const hasAllowFrom = (cfg.routing?.allowFrom?.length ?? 0) > 0; + const hasAllowFrom = (cfg.whatsapp?.allowFrom?.length ?? 0) > 0; messagePrefix = hasAllowFrom ? "" : "[clawdis]"; } const prefixStr = messagePrefix ? `${messagePrefix} ` : ""; diff --git a/src/web/inbound.media.test.ts b/src/web/inbound.media.test.ts index 175cad76d..d350bdad7 100644 --- a/src/web/inbound.media.test.ts +++ b/src/web/inbound.media.test.ts @@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; vi.mock("../config/config.js", () => ({ loadConfig: vi.fn().mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["*"], // Allow all in tests }, messages: { diff --git a/src/web/inbound.ts b/src/web/inbound.ts index aa96077b6..ba2c4e6ba 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -157,7 +157,7 @@ export async function monitorWebInbox(options: { // Filter unauthorized senders early to prevent wasted processing // and potential session corruption from Bad MAC errors const cfg = loadConfig(); - const configuredAllowFrom = cfg.routing?.allowFrom; + const configuredAllowFrom = cfg.whatsapp?.allowFrom; // Without user config, default to self-only DM access so the owner can talk to themselves const defaultAllowFrom = (!configuredAllowFrom || configuredAllowFrom.length === 0) && selfE164 diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index b4ee36cb2..e26ec3035 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -10,7 +10,7 @@ vi.mock("../media/store.js", () => ({ })); const mockLoadConfig = vi.fn().mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["*"], // Allow all in tests by default }, messages: { @@ -450,7 +450,7 @@ describe("web monitor inbox", () => { it("still forwards group messages (with sender info) even when allowFrom is restrictive", async () => { mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["+111"], // does not include +777 }, messages: { @@ -506,7 +506,7 @@ describe("web monitor inbox", () => { // Test for auto-recovery fix: early allowFrom filtering prevents Bad MAC errors // from unauthorized senders corrupting sessions mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["+111"], // Only allow +111 }, messages: { @@ -546,7 +546,7 @@ describe("web monitor inbox", () => { // Reset mock for other tests mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { @@ -561,7 +561,7 @@ describe("web monitor inbox", () => { it("skips read receipts in self-chat mode", async () => { mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { // Self-chat heuristic: allowFrom includes selfE164 (+123). allowFrom: ["+123"], }, @@ -598,7 +598,7 @@ describe("web monitor inbox", () => { // Reset mock for other tests mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { @@ -613,7 +613,7 @@ describe("web monitor inbox", () => { it("lets group messages through even when sender not in allowFrom", async () => { mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["+1234"], }, messages: { @@ -655,7 +655,7 @@ describe("web monitor inbox", () => { it("allows messages from senders in allowFrom list", async () => { mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["+111", "+999"], // Allow +999 }, messages: { @@ -690,7 +690,7 @@ describe("web monitor inbox", () => { // Reset mock for other tests mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { @@ -707,7 +707,7 @@ describe("web monitor inbox", () => { // Same-phone mode: when from === selfJid, should always be allowed // This allows users to message themselves even with restrictive allowFrom mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["+111"], // Only allow +111, but self is +123 }, messages: { @@ -810,7 +810,7 @@ it("defaults to self-only when no config is present", async () => { // Reset mock for other tests mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { diff --git a/src/web/test-helpers.ts b/src/web/test-helpers.ts index 21a6a5b9d..09cf84c57 100644 --- a/src/web/test-helpers.ts +++ b/src/web/test-helpers.ts @@ -6,7 +6,7 @@ import { createMockBaileys } from "../../test/mocks/baileys.js"; // Use globalThis to store the mock config so it survives vi.mock hoisting const CONFIG_KEY = Symbol.for("clawdis:testConfigMock"); const DEFAULT_CONFIG = { - routing: { + whatsapp: { // Tests can override; default remains open to avoid surprising fixtures allowFrom: ["*"], }, From b9b862a3802d74be51945eb82ce09b07cd17d36d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 12:59:56 +0100 Subject: [PATCH 05/44] chore: note doctor migration commit --- src/commands/doctor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index da8072a53..607b3870a 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -27,7 +27,7 @@ type LegacyMigration = { }; const LEGACY_MIGRATIONS: LegacyMigration[] = [ - // Legacy migration (2026-01-02, commit: TBD) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. + // Legacy migration (2026-01-02, commit: 3c6b59d8) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. { id: "routing.allowFrom->whatsapp.allowFrom", describe: "Move routing.allowFrom to whatsapp.allowFrom", From 55665246bb24131556dbe3d171c3bfa3693ed9a8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:00:44 +0100 Subject: [PATCH 06/44] chore: refresh doctor migration commit --- src/commands/doctor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 607b3870a..1758dff0b 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -27,7 +27,7 @@ type LegacyMigration = { }; const LEGACY_MIGRATIONS: LegacyMigration[] = [ - // Legacy migration (2026-01-02, commit: 3c6b59d8) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. + // Legacy migration (2026-01-02, commit: 0766c5e3) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. { id: "routing.allowFrom->whatsapp.allowFrom", describe: "Move routing.allowFrom to whatsapp.allowFrom", From 16420e5b31f08f2b7848bff5da463b915a9f9934 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:07:14 +0100 Subject: [PATCH 07/44] refactor: auto-migrate legacy config in gateway --- src/cli/program.ts | 2 +- src/commands/doctor.test.ts | 11 +++-- src/commands/doctor.ts | 80 +++++++------------------------------ src/config/config.test.ts | 11 +++++ src/config/config.ts | 58 +++++++++++++++++++++++++++ src/gateway/server.test.ts | 4 ++ src/gateway/server.ts | 26 ++++++++++++ 7 files changed, 123 insertions(+), 69 deletions(-) diff --git a/src/cli/program.ts b/src/cli/program.ts index d5e9d6a52..926094987 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -79,7 +79,7 @@ export function buildProgram() { .join("\n"); defaultRuntime.error( danger( - `Legacy config entries detected. Ask your agent to run \"clawdis doctor\" to migrate.\n${issues}`, + `Legacy config entries detected. Run \"clawdis doctor\" (or ask your agent) to migrate.\n${issues}`, ), ); process.exit(1); diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index a988a370d..000e6c01c 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it, vi } from "vitest"; const readConfigFileSnapshot = vi.fn(); const writeConfigFile = vi.fn().mockResolvedValue(undefined); -const validateConfigObject = vi.fn((raw: unknown) => ({ - ok: true as const, +const migrateLegacyConfig = vi.fn((raw: unknown) => ({ config: raw as Record, + changes: ["Moved routing.allowFrom → whatsapp.allowFrom."], })); vi.mock("@clack/prompts", () => ({ @@ -22,7 +22,7 @@ vi.mock("../config/config.js", () => ({ CONFIG_PATH_CLAWDIS: "/tmp/clawdis.json", readConfigFileSnapshot, writeConfigFile, - validateConfigObject, + migrateLegacyConfig, })); vi.mock("../runtime.js", () => ({ @@ -81,6 +81,11 @@ describe("doctor", () => { exit: vi.fn(), }; + migrateLegacyConfig.mockReturnValue({ + config: { whatsapp: { allowFrom: ["+15555550123"] } }, + changes: ["Moved routing.allowFrom → whatsapp.allowFrom."], + }); + await doctorCommand(runtime); expect(writeConfigFile).toHaveBeenCalledTimes(1); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 1758dff0b..ac35fbe30 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -4,8 +4,8 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import type { ClawdisConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDIS, + migrateLegacyConfig, readConfigFileSnapshot, - validateConfigObject, writeConfigFile, } from "../config/config.js"; import { resolveGatewayService } from "../daemon/service.js"; @@ -20,65 +20,6 @@ import { printWizardHeader, } from "./onboard-helpers.js"; -type LegacyMigration = { - id: string; - describe: string; - apply: (raw: Record, changes: string[]) => void; -}; - -const LEGACY_MIGRATIONS: LegacyMigration[] = [ - // Legacy migration (2026-01-02, commit: 0766c5e3) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. - { - id: "routing.allowFrom->whatsapp.allowFrom", - describe: "Move routing.allowFrom to whatsapp.allowFrom", - apply: (raw, changes) => { - const routing = raw.routing; - if (!routing || typeof routing !== "object") return; - const allowFrom = (routing as Record).allowFrom; - if (allowFrom === undefined) return; - - const whatsapp = - raw.whatsapp && typeof raw.whatsapp === "object" - ? (raw.whatsapp as Record) - : {}; - - if (whatsapp.allowFrom === undefined) { - whatsapp.allowFrom = allowFrom; - changes.push("Moved routing.allowFrom → whatsapp.allowFrom."); - } else { - changes.push("Removed routing.allowFrom (whatsapp.allowFrom already set)."); - } - - delete (routing as Record).allowFrom; - if (Object.keys(routing as Record).length === 0) { - delete raw.routing; - } - raw.whatsapp = whatsapp; - }, - }, -]; - -function applyLegacyMigrations(raw: unknown): { - config: ClawdisConfig | null; - changes: string[]; -} { - if (!raw || typeof raw !== "object") return { config: null, changes: [] }; - const next = structuredClone(raw) as Record; - const changes: string[] = []; - for (const migration of LEGACY_MIGRATIONS) { - migration.apply(next, changes); - } - if (changes.length === 0) return { config: null, changes: [] }; - const validated = validateConfigObject(next); - if (!validated.ok) { - changes.push( - "Migration applied, but config still invalid; fix remaining issues manually.", - ); - return { config: null, changes }; - } - return { config: validated.config, changes }; -} - function resolveMode(cfg: ClawdisConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; } @@ -108,9 +49,8 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { runtime, ); if (migrate) { - const { config: migrated, changes } = applyLegacyMigrations( - snapshot.parsed, - ); + // Legacy migration (2026-01-02, commit: 0766c5e3) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. + const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed); if (changes.length > 0) { note(changes.join("\n"), "Doctor changes"); } @@ -144,7 +84,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); healthOk = true; } catch (err) { - runtime.error(`Health check failed: ${String(err)}`); + const message = String(err); + if (message.includes("gateway closed")) { + note("Gateway not running.", "Gateway"); + } else { + runtime.error(`Health check failed: ${message}`); + } } if (!healthOk) { @@ -166,7 +111,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { try { await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); } catch (err) { - runtime.error(`Health check failed: ${String(err)}`); + const message = String(err); + if (message.includes("gateway closed")) { + note("Gateway not running.", "Gateway"); + } else { + runtime.error(`Health check failed: ${message}`); + } } } } diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 06e9e8377..4d46782fe 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -502,6 +502,17 @@ describe("legacy config detection", () => { } }); + it("migrates routing.allowFrom to whatsapp.allowFrom", async () => { + vi.resetModules(); + const { migrateLegacyConfig } = await import("./config.js"); + const res = migrateLegacyConfig({ + routing: { allowFrom: ["+15555550123"] }, + }); + expect(res.changes).toContain("Moved routing.allowFrom → whatsapp.allowFrom."); + expect(res.config?.whatsapp?.allowFrom).toEqual(["+15555550123"]); + expect(res.config?.routing?.allowFrom).toBeUndefined(); + }); + it("surfaces legacy issues in snapshot", async () => { await withTempHome(async (home) => { const configPath = path.join(home, ".clawdis", "clawdis.json"); diff --git a/src/config/config.ts b/src/config/config.ts index 9c217fc95..2ea0300bf 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1161,6 +1161,12 @@ type LegacyConfigRule = { message: string; }; +type LegacyConfigMigration = { + id: string; + describe: string; + apply: (raw: Record, changes: string[]) => void; +}; + const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ { path: ["routing", "allowFrom"], @@ -1169,6 +1175,37 @@ const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ }, ]; +const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ + { + id: "routing.allowFrom->whatsapp.allowFrom", + describe: "Move routing.allowFrom to whatsapp.allowFrom", + apply: (raw, changes) => { + const routing = raw.routing; + if (!routing || typeof routing !== "object") return; + const allowFrom = (routing as Record).allowFrom; + if (allowFrom === undefined) return; + + const whatsapp = + raw.whatsapp && typeof raw.whatsapp === "object" + ? (raw.whatsapp as Record) + : {}; + + if (whatsapp.allowFrom === undefined) { + whatsapp.allowFrom = allowFrom; + changes.push("Moved routing.allowFrom → whatsapp.allowFrom."); + } else { + changes.push("Removed routing.allowFrom (whatsapp.allowFrom already set)."); + } + + delete (routing as Record).allowFrom; + if (Object.keys(routing as Record).length === 0) { + delete raw.routing; + } + raw.whatsapp = whatsapp; + }, + }, +]; + function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { if (!raw || typeof raw !== "object") return []; const root = raw as Record; @@ -1189,6 +1226,27 @@ function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { return issues; } +export function migrateLegacyConfig(raw: unknown): { + config: ClawdisConfig | null; + changes: string[]; +} { + if (!raw || typeof raw !== "object") return { config: null, changes: [] }; + const next = structuredClone(raw) as Record; + const changes: string[] = []; + for (const migration of LEGACY_CONFIG_MIGRATIONS) { + migration.apply(next, changes); + } + if (changes.length === 0) return { config: null, changes: [] }; + const validated = validateConfigObject(next); + if (!validated.ok) { + changes.push( + "Migration applied, but config still invalid; fix remaining issues manually.", + ); + return { config: null, changes }; + } + return { config: validated.config, changes }; +} + function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 2c58d039f..f9e059693 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -204,6 +204,10 @@ vi.mock("../config/config.js", () => { CONFIG_PATH_CLAWDIS: resolveConfigPath(), STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()), isNixMode: false, + migrateLegacyConfig: (raw: unknown) => ({ + config: raw as Record, + changes: [], + }), loadConfig: () => ({ agent: { model: "anthropic/claude-opus-4-5", diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 979e04188..1ffb6724c 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -48,6 +48,7 @@ import { CONFIG_PATH_CLAWDIS, isNixMode, loadConfig, + migrateLegacyConfig, parseConfigJson5, readConfigFileSnapshot, STATE_DIR_CLAWDIS, @@ -1322,6 +1323,31 @@ export async function startGatewayServer( port = 18789, opts: GatewayServerOptions = {}, ): Promise { + const configSnapshot = await readConfigFileSnapshot(); + if (configSnapshot.legacyIssues.length > 0) { + if (isNixMode) { + throw new Error( + "Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.", + ); + } + const { config: migrated, changes } = migrateLegacyConfig( + configSnapshot.parsed, + ); + if (!migrated) { + throw new Error( + "Legacy config entries detected but auto-migration failed. Run \"clawdis doctor\" to migrate.", + ); + } + await writeConfigFile(migrated); + if (changes.length > 0) { + log.info( + `gateway: migrated legacy config entries:\n${changes + .map((entry) => `- ${entry}`) + .join("\n")}`, + ); + } + } + const cfgAtStart = loadConfig(); const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback"; const bindHost = opts.host ?? resolveGatewayBindHost(bindMode); From 7b72b35cca273f6e96c68ddc105744b0eacbaa39 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:07:26 +0100 Subject: [PATCH 08/44] chore: update doctor migration hash --- src/commands/doctor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index ac35fbe30..22696e9e8 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -49,7 +49,7 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { runtime, ); if (migrate) { - // Legacy migration (2026-01-02, commit: 0766c5e3) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. + // Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed); if (changes.length > 0) { note(changes.join("\n"), "Doctor changes"); From ecef49605be967cff4a730d42b3660afa452eefa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:09:41 +0100 Subject: [PATCH 09/44] test: cover gateway legacy auto-migrate --- src/gateway/server.test.ts | 69 +++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index f9e059693..2bdb753c2 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -130,6 +130,11 @@ let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined; let testGatewayAuth: Record | undefined; let testHooksConfig: Record | undefined; let testCanvasHostPort: number | undefined; +let testLegacyIssues: Array<{ path: string; message: string }> = []; +let testLegacyParsed: Record = {}; +let testMigrationConfig: Record | null = null; +let testMigrationChanges: string[] = []; +let testIsNixMode = false; const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 })); vi.mock("../config/sessions.js", async () => { const actual = await vi.importActual( @@ -151,6 +156,21 @@ vi.mock("../config/config.js", () => { path.join(os.homedir(), ".clawdis", "clawdis.json"); const readConfigFileSnapshot = async () => { + if (testLegacyIssues.length > 0) { + return { + path: resolveConfigPath(), + exists: true, + raw: JSON.stringify(testLegacyParsed ?? {}), + parsed: testLegacyParsed ?? {}, + valid: false, + config: {}, + issues: testLegacyIssues.map((issue) => ({ + path: issue.path, + message: issue.message, + })), + legacyIssues: testLegacyIssues, + }; + } const configPath = resolveConfigPath(); try { await fs.access(configPath); @@ -193,20 +213,20 @@ vi.mock("../config/config.js", () => { } }; - const writeConfigFile = async (cfg: Record) => { + const writeConfigFile = vi.fn(async (cfg: Record) => { const configPath = resolveConfigPath(); await fs.mkdir(path.dirname(configPath), { recursive: true }); const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n"); await fs.writeFile(configPath, raw, "utf-8"); - }; + }); return { CONFIG_PATH_CLAWDIS: resolveConfigPath(), STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()), - isNixMode: false, + isNixMode: testIsNixMode, migrateLegacyConfig: (raw: unknown) => ({ - config: raw as Record, - changes: [], + config: testMigrationConfig ?? (raw as Record), + changes: testMigrationChanges, }), loadConfig: () => ({ agent: { @@ -286,6 +306,11 @@ beforeEach(async () => { testGatewayAuth = undefined; testHooksConfig = undefined; testCanvasHostPort = undefined; + testLegacyIssues = []; + testLegacyParsed = {}; + testMigrationConfig = null; + testMigrationChanges = []; + testIsNixMode = false; cronIsolatedRun.mockClear(); drainSystemEvents(); resetAgentRunContextForTest(); @@ -523,6 +548,40 @@ describe("gateway server", () => { }, ); + test("auto-migrates legacy config on startup", async () => { + (writeConfigFile as unknown as { mockClear?: () => void })?.mockClear?.(); + testLegacyIssues = [ + { + path: "routing.allowFrom", + message: "legacy", + }, + ]; + testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } }; + testMigrationConfig = { whatsapp: { allowFrom: ["+15555550123"] } }; + testMigrationChanges = ["Moved routing.allowFrom → whatsapp.allowFrom."]; + + const port = await getFreePort(); + const server = await startGatewayServer(port); + expect(writeConfigFile).toHaveBeenCalledWith(testMigrationConfig); + await server.close(); + }); + + test("fails in Nix mode when legacy config is present", async () => { + testLegacyIssues = [ + { + path: "routing.allowFrom", + message: "legacy", + }, + ]; + testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } }; + testIsNixMode = true; + + const port = await getFreePort(); + await expect(startGatewayServer(port)).rejects.toThrow( + "Legacy config entries detected while running in Nix mode", + ); + }); + test("models.list returns model catalog", async () => { piSdkMock.enabled = true; piSdkMock.models = [ From 17e17f85ae7064379a71c47ca4f7c9ecb041cf46 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:10:09 +0100 Subject: [PATCH 10/44] docs: note gateway auto-migrate --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c4cb842..1d82e2c00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,7 @@ - iOS/Android Talk Mode: explicitly `chat.subscribe` when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open. - Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently. - Gateway: `voice.transcript` now also maps agent bus output to `chat` events, ensuring chat UIs refresh for voice-triggered runs. +- Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present. - iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled. - Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand. - Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast. From b135b3efb9184a562d9a0b35331f8b521c8f5284 Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 2 Jan 2026 00:11:03 -0600 Subject: [PATCH 11/44] Discord: add slash command handling --- README.md | 2 +- docs/configuration.md | 6 + docs/discord.md | 18 ++- src/auto-reply/templating.ts | 1 + src/config/config.ts | 20 +++ src/config/sessions.ts | 2 + src/discord/monitor.ts | 290 ++++++++++++++++++++++++++++++++++- src/gateway/server.ts | 1 + 8 files changed, 336 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 102bb644c..78c372992 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Minimal `~/.clawdis/clawdis.json`: ### Discord - Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins). -- Optional: set `discord.requireMention`, `discord.allowFrom`, or `discord.mediaMaxMb` as needed. +- Optional: set `discord.requireMention`, `discord.slashCommand`, `discord.allowFrom`, or `discord.mediaMaxMb` as needed. ```json5 { diff --git a/docs/configuration.md b/docs/configuration.md index 38bee9cbf..5a90a0a53 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -175,6 +175,12 @@ Configure the Discord bot by setting the bot token and optional gating: token: "your-bot-token", mediaMaxMb: 8, // clamp inbound media size enableReactions: true, // allow agent-triggered reactions + slashCommand: { // user-installed app slash commands + enabled: true, + name: "clawd", + sessionPrefix: "discord:slash", + ephemeral: true + }, dm: { enabled: true, // disable all DMs when false allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names) diff --git a/docs/discord.md b/docs/discord.md index f482588e3..ad6bf7126 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -25,8 +25,9 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa 6. Guild channels: use `channel:` for delivery. Mentions are required by default and can be set per guild or per channel. 7. Optional DM control: set `discord.dm.enabled = false` to ignore all DMs, or `discord.dm.allowFrom` to allow specific users (ids or names). Use `discord.dm.groupEnabled` + `discord.dm.groupChannels` to allow group DMs. 8. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules. -9. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable. -10. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool. +9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists. +10. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable. +11. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool. Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets. Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`. @@ -47,6 +48,12 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea token: "abc.123", mediaMaxMb: 8, enableReactions: true, + slashCommand: { + enabled: true, + name: "clawd", + sessionPrefix: "discord:slash", + ephemeral: true + }, dm: { enabled: true, allowFrom: ["123456789012345678", "steipete"], @@ -77,10 +84,17 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea - `guilds..users`: optional per-guild user allowlist (ids or names). - `guilds..channels`: channel rules (keys are channel slugs or ids). - `guilds..requireMention`: per-guild mention requirement (overridable per channel). +- `slashCommand`: optional config for user-installed slash commands (ephemeral responses). - `mediaMaxMb`: clamp inbound media saved to disk. - `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables). - `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`). +Slash command notes: +- Register a chat input command in Discord with at least one string option (e.g., `prompt`). +- The first non-empty string option is treated as the prompt. +- Slash commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules). +- Clawdis will auto-register `/clawd` (or the configured name) if it doesn't already exist. + ## Reactions When `discord.enableReactions = true`, the agent can call `clawdis_discord` with: - `action: "react"` diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 3e3c0ac59..2215a127b 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -2,6 +2,7 @@ export type MsgContext = { Body?: string; From?: string; To?: string; + SessionKey?: string; MessageSid?: string; ReplyToId?: string; ReplyToBody?: string; diff --git a/src/config/config.ts b/src/config/config.ts index 2ea0300bf..3525ebcf7 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -192,6 +192,17 @@ export type DiscordGuildEntry = { channels?: Record; }; +export type DiscordSlashCommandConfig = { + /** Enable handling for the configured slash command (default: false). */ + enabled?: boolean; + /** Slash command name (default: "clawd"). */ + name?: string; + /** Session key prefix for slash commands (default: "discord:slash"). */ + sessionPrefix?: string; + /** Reply ephemerally (default: true). */ + ephemeral?: boolean; +}; + export type DiscordConfig = { /** If false, do not start the Discord provider. Default: true. */ enabled?: boolean; @@ -200,6 +211,7 @@ export type DiscordConfig = { historyLimit?: number; /** Allow agent-triggered Discord reactions (default: true). */ enableReactions?: boolean; + slashCommand?: DiscordSlashCommandConfig; dm?: DiscordDmConfig; /** New per-guild config keyed by guild id or slug. */ guilds?: Record; @@ -936,6 +948,14 @@ const ClawdisSchema = z.object({ .object({ enabled: z.boolean().optional(), token: z.string().optional(), + slashCommand: z + .object({ + enabled: z.boolean().optional(), + name: z.string().optional(), + sessionPrefix: z.string().optional(), + ephemeral: z.boolean().optional(), + }) + .optional(), mediaMaxMb: z.number().positive().optional(), historyLimit: z.number().int().min(0).optional(), enableReactions: z.boolean().optional(), diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 4768fd368..7457bb0d2 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -349,6 +349,8 @@ export function resolveSessionKey( ctx: MsgContext, mainKey?: string, ) { + const explicit = ctx.SessionKey?.trim(); + if (explicit) return explicit; const raw = deriveSessionKey(scope, ctx); if (scope === "global") return raw; // Default to a single shared direct-chat session called "main"; groups stay isolated. diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 78c5708ea..5a542c7b2 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,5 +1,7 @@ import { + ApplicationCommandOptionType, ChannelType, + type CommandInteractionOption, Client, Events, GatewayIntentBits, @@ -11,20 +13,23 @@ import { chunkText } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; +import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { loadConfig } from "../config/config.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; -import { danger, isVerbose, logVerbose } from "../globals.js"; +import { danger, isVerbose, logVerbose, warn } from "../globals.js"; import { getChildLogger } from "../logging.js"; import { detectMime } from "../media/mime.js"; import { saveMediaBuffer } from "../media/store.js"; import type { RuntimeEnv } from "../runtime.js"; import { sendMessageDiscord } from "./send.js"; import { normalizeDiscordToken } from "./token.js"; +import type { DiscordSlashCommandConfig } from "../config/config.js"; export type MonitorDiscordOpts = { token?: string; runtime?: RuntimeEnv; abortSignal?: AbortSignal; + slashCommand?: DiscordSlashCommandConfig; mediaMaxMb?: number; historyLimit?: number; }; @@ -86,6 +91,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const dmConfig = cfg.discord?.dm; const guildEntries = cfg.discord?.guilds; const allowFrom = dmConfig?.allowFrom; + const slashCommand = resolveSlashCommandConfig( + opts.slashCommand ?? cfg.discord?.slashCommand, + ); const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; const historyLimit = Math.max( @@ -111,6 +119,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { client.once(Events.ClientReady, () => { runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`); + if (slashCommand.enabled) { + void ensureSlashCommand(client, slashCommand, runtime); + } }); client.on(Events.Error, (err) => { @@ -376,6 +387,159 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } }); + client.on(Events.InteractionCreate, async (interaction) => { + try { + if (!slashCommand.enabled) return; + if (!interaction.isChatInputCommand()) return; + if (interaction.commandName !== slashCommand.name) return; + if (interaction.user?.bot) return; + + const channelType = interaction.channel?.type as ChannelType | undefined; + const isGroupDm = channelType === ChannelType.GroupDM; + const isDirectMessage = + !interaction.inGuild() && channelType === ChannelType.DM; + const isGuildMessage = interaction.inGuild(); + + if (isGroupDm && !groupDmEnabled) return; + if (isDirectMessage && !dmEnabled) return; + + if (isGuildMessage) { + const guildInfo = resolveDiscordGuildEntry({ + guild: interaction.guild ?? null, + guildEntries, + }); + if ( + guildEntries && + Object.keys(guildEntries).length > 0 && + !guildInfo + ) { + logVerbose( + `Blocked discord guild ${interaction.guildId ?? "unknown"} (not in discord.guilds)`, + ); + return; + } + const channelName = + interaction.channel && "name" in interaction.channel + ? interaction.channel.name + : undefined; + const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: interaction.channelId, + channelName, + channelSlug, + }); + if (channelConfig?.allowed === false) { + logVerbose( + `Blocked discord channel ${interaction.channelId} not in guild channel allowlist`, + ); + return; + } + const userAllow = guildInfo?.users; + if (Array.isArray(userAllow) && userAllow.length > 0) { + const users = normalizeDiscordAllowList(userAllow, [ + "discord:", + "user:", + ]); + const userOk = + !users || + allowListMatches(users, { + id: interaction.user.id, + name: interaction.user.username, + tag: interaction.user.tag, + }); + if (!userOk) { + logVerbose( + `Blocked discord guild sender ${interaction.user.id} (not in guild users allowlist)`, + ); + return; + } + } + } else if (isGroupDm) { + const channelName = + interaction.channel && "name" in interaction.channel + ? interaction.channel.name + : undefined; + const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const groupDmAllowed = resolveGroupDmAllow({ + channels: groupDmChannels, + channelId: interaction.channelId, + channelName, + channelSlug, + }); + if (!groupDmAllowed) return; + } else if (isDirectMessage) { + if (Array.isArray(allowFrom) && allowFrom.length > 0) { + const allowList = normalizeDiscordAllowList(allowFrom, [ + "discord:", + "user:", + ]); + const permitted = + allowList && + allowListMatches(allowList, { + id: interaction.user.id, + name: interaction.user.username, + tag: interaction.user.tag, + }); + if (!permitted) { + logVerbose( + `Blocked unauthorized discord sender ${interaction.user.id} (not in allowFrom)`, + ); + return; + } + } + } + + const prompt = resolveSlashPrompt(interaction.options.data); + if (!prompt) { + await interaction.reply({ + content: "Message required.", + ephemeral: true, + }); + return; + } + + await interaction.deferReply({ ephemeral: slashCommand.ephemeral }); + + const userId = interaction.user.id; + const ctxPayload = { + Body: prompt, + From: `discord:${userId}`, + To: `slash:${userId}`, + ChatType: "direct", + SenderName: interaction.user.username, + Surface: "discord" as const, + WasMentioned: true, + MessageSid: interaction.id, + Timestamp: interaction.createdTimestamp, + SessionKey: `${slashCommand.sessionPrefix}:${userId}`, + }; + + const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg); + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + + await deliverSlashReplies({ + replies, + interaction, + ephemeral: slashCommand.ephemeral, + }); + } catch (err) { + runtime.error?.(danger(`slash handler failed: ${String(err)}`)); + if (interaction.isRepliable()) { + const content = "Sorry, something went wrong handling that command."; + if (interaction.deferred || interaction.replied) { + await interaction.followUp({ content, ephemeral: true }); + } else { + await interaction.reply({ content, ephemeral: true }); + } + } + } + }); + await client.login(token); await new Promise((resolve, reject) => { @@ -614,6 +778,88 @@ export function resolveGroupDmAllow(params: { }); } +async function ensureSlashCommand( + client: Client, + slashCommand: Required, + runtime: RuntimeEnv, +) { + try { + const appCommands = client.application?.commands; + if (!appCommands) { + runtime.error?.(danger("discord slash commands unavailable")); + return; + } + const existing = await appCommands.fetch(); + const hasCommand = Array.from(existing.values()).some( + (entry) => entry.name === slashCommand.name, + ); + if (hasCommand) return; + await appCommands.create({ + name: slashCommand.name, + description: "Ask Clawdis a question", + options: [ + { + name: "prompt", + description: "What should Clawdis help with?", + type: ApplicationCommandOptionType.String, + required: true, + }, + ], + }); + runtime.log?.(`registered discord slash command /${slashCommand.name}`); + } catch (err) { + const status = (err as { status?: number | string })?.status; + const code = (err as { code?: number | string })?.code; + const message = String(err); + const isRateLimit = + status === 429 || + code === 429 || + /rate ?limit/i.test(message); + const text = `discord slash command setup failed: ${message}`; + if (isRateLimit) { + logVerbose(text); + runtime.error?.(warn(text)); + } else { + runtime.error?.(danger(text)); + } + } +} + +function resolveSlashCommandConfig( + raw: DiscordSlashCommandConfig | undefined, +): Required { + return { + enabled: raw ? raw.enabled !== false : false, + name: raw?.name?.trim() || "clawd", + sessionPrefix: raw?.sessionPrefix?.trim() || "discord:slash", + ephemeral: raw?.ephemeral !== false, + }; +} + +function resolveSlashPrompt( + options: readonly CommandInteractionOption[], +): string | undefined { + const direct = findFirstStringOption(options); + if (direct) return direct; + return undefined; +} + +function findFirstStringOption( + options: readonly CommandInteractionOption[], +): string | undefined { + for (const option of options) { + if (typeof option.value === "string") { + const trimmed = option.value.trim(); + if (trimmed) return trimmed; + } + if (option.options && option.options.length > 0) { + const nested = findFirstStringOption(option.options); + if (nested) return nested; + } + } + return undefined; +} + async function sendTyping(message: Message) { try { const channel = message.channel; @@ -659,3 +905,45 @@ async function deliverReplies({ runtime.log?.(`delivered reply to ${target}`); } } + +async function deliverSlashReplies({ + replies, + interaction, + ephemeral, +}: { + replies: ReplyPayload[]; + interaction: import("discord.js").ChatInputCommandInteraction; + ephemeral: boolean; +}) { + const messages: string[] = []; + for (const payload of replies) { + const textRaw = payload.text?.trim() ?? ""; + const text = + textRaw && textRaw !== SILENT_REPLY_TOKEN ? textRaw : undefined; + const mediaList = + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const combined = [ + text ?? "", + ...mediaList.map((url) => url.trim()).filter(Boolean), + ] + .filter(Boolean) + .join("\n"); + if (!combined) continue; + for (const chunk of chunkText(combined, 2000)) { + messages.push(chunk); + } + } + + if (messages.length === 0) { + await interaction.editReply({ + content: "No response was generated for that command.", + }); + return; + } + + const [first, ...rest] = messages; + await interaction.editReply({ content: first }); + for (const message of rest) { + await interaction.followUp({ content: message, ephemeral }); + } +} diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 1ffb6724c..d29974391 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -2254,6 +2254,7 @@ export async function startGatewayServer( token: discordToken.trim(), runtime: discordRuntimeEnv, abortSignal: discordAbort.signal, + slashCommand: cfg.discord?.slashCommand, mediaMaxMb: cfg.discord?.mediaMaxMb, historyLimit: cfg.discord?.historyLimit, }) From fff9efe8a83e2f6b8d1359cad14e0f280bee3506 Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 2 Jan 2026 00:44:06 -0600 Subject: [PATCH 12/44] Discord: auto-register slash command --- src/discord/monitor.ts | 52 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 5a542c7b2..1b3b95101 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -844,6 +844,58 @@ function resolveSlashPrompt( return undefined; } +async function ensureSlashCommand( + client: Client, + slashCommand: Required, + runtime: RuntimeEnv, +) { + try { + const appCommands = client.application?.commands; + if (!appCommands) { + runtime.error?.(danger("discord slash commands unavailable")); + return; + } + const existing = await appCommands.fetch(); + let hasCommand = false; + for (const entry of existing.values()) { + if (entry.name === slashCommand.name) { + hasCommand = true; + continue; + } + await entry.delete(); + } + if (hasCommand) return; + await appCommands.create({ + name: slashCommand.name, + description: "Ask Clawdis a question", + options: [ + { + name: "prompt", + description: "What should Clawdis help with?", + type: ApplicationCommandOptionType.String, + required: true, + }, + ], + }); + runtime.log?.(`registered discord slash command /${slashCommand.name}`); + } catch (err) { + const status = (err as { status?: number | string })?.status; + const code = (err as { code?: number | string })?.code; + const message = String(err); + const isRateLimit = + status === 429 || + code === 429 || + /rate ?limit/i.test(message); + const text = `discord slash command setup failed: ${message}`; + if (isRateLimit) { + logVerbose(text); + runtime.error?.(warn(text)); + } else { + runtime.error?.(danger(text)); + } + } +} + function findFirstStringOption( options: readonly CommandInteractionOption[], ): string | undefined { From 5f103e32bdeea0f5d29c4ed075bccbe876f4eac3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:33:52 +0100 Subject: [PATCH 13/44] fix: gate discord slash commands --- CHANGELOG.md | 1 + README.md | 2 +- src/discord/monitor.ts | 52 ------------------------------------------ 3 files changed, 2 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d82e2c00..fd4368499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android). - Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward). - Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`). +- Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow. - Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching. - Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider. - iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support. diff --git a/README.md b/README.md index 78c372992..9f0dfd62d 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Minimal `~/.clawdis/clawdis.json`: ### Discord - Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins). -- Optional: set `discord.requireMention`, `discord.slashCommand`, `discord.allowFrom`, or `discord.mediaMaxMb` as needed. +- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed. ```json5 { diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 1b3b95101..5a542c7b2 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -844,58 +844,6 @@ function resolveSlashPrompt( return undefined; } -async function ensureSlashCommand( - client: Client, - slashCommand: Required, - runtime: RuntimeEnv, -) { - try { - const appCommands = client.application?.commands; - if (!appCommands) { - runtime.error?.(danger("discord slash commands unavailable")); - return; - } - const existing = await appCommands.fetch(); - let hasCommand = false; - for (const entry of existing.values()) { - if (entry.name === slashCommand.name) { - hasCommand = true; - continue; - } - await entry.delete(); - } - if (hasCommand) return; - await appCommands.create({ - name: slashCommand.name, - description: "Ask Clawdis a question", - options: [ - { - name: "prompt", - description: "What should Clawdis help with?", - type: ApplicationCommandOptionType.String, - required: true, - }, - ], - }); - runtime.log?.(`registered discord slash command /${slashCommand.name}`); - } catch (err) { - const status = (err as { status?: number | string })?.status; - const code = (err as { code?: number | string })?.code; - const message = String(err); - const isRateLimit = - status === 429 || - code === 429 || - /rate ?limit/i.test(message); - const text = `discord slash command setup failed: ${message}`; - if (isRateLimit) { - logVerbose(text); - runtime.error?.(warn(text)); - } else { - runtime.error?.(danger(text)); - } - } -} - function findFirstStringOption( options: readonly CommandInteractionOption[], ): string | undefined { From 1e04481aaf76c577fb4e5a89e7898331cc0e6126 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:37:45 +0100 Subject: [PATCH 14/44] style: format discord slash handler --- src/discord/monitor.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 5a542c7b2..e5449074a 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,8 +1,8 @@ import { ApplicationCommandOptionType, ChannelType, - type CommandInteractionOption, Client, + type CommandInteractionOption, Events, GatewayIntentBits, type Message, @@ -12,8 +12,9 @@ import { import { chunkText } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; -import type { ReplyPayload } from "../auto-reply/types.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import type { ReplyPayload } from "../auto-reply/types.js"; +import type { DiscordSlashCommandConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, isVerbose, logVerbose, warn } from "../globals.js"; @@ -23,7 +24,6 @@ import { saveMediaBuffer } from "../media/store.js"; import type { RuntimeEnv } from "../runtime.js"; import { sendMessageDiscord } from "./send.js"; import { normalizeDiscordToken } from "./token.js"; -import type { DiscordSlashCommandConfig } from "../config/config.js"; export type MonitorDiscordOpts = { token?: string; @@ -422,7 +422,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { interaction.channel && "name" in interaction.channel ? interaction.channel.name : undefined; - const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const channelSlug = channelName + ? normalizeDiscordSlug(channelName) + : ""; const channelConfig = resolveDiscordChannelConfig({ guildInfo, channelId: interaction.channelId, @@ -460,7 +462,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { interaction.channel && "name" in interaction.channel ? interaction.channel.name : undefined; - const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const channelSlug = channelName + ? normalizeDiscordSlug(channelName) + : ""; const groupDmAllowed = resolveGroupDmAllow({ channels: groupDmChannels, channelId: interaction.channelId, @@ -812,9 +816,7 @@ async function ensureSlashCommand( const code = (err as { code?: number | string })?.code; const message = String(err); const isRateLimit = - status === 429 || - code === 429 || - /rate ?limit/i.test(message); + status === 429 || code === 429 || /rate ?limit/i.test(message); const text = `discord slash command setup failed: ${message}`; if (isRateLimit) { logVerbose(text); From 5ecb65cbbed94d7c6b0e76cc12d0b23a01cef88e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:46:48 +0100 Subject: [PATCH 15/44] fix: persist gateway token for local CLI auth --- CHANGELOG.md | 1 + docs/configuration.md | 3 ++- docs/onboarding.md | 4 ++++ docs/wizard.md | 1 + src/commands/onboard-interactive.ts | 19 ++++++++++++++++--- src/config/config.ts | 3 +++ src/gateway/auth.ts | 2 +- src/gateway/call.ts | 16 +++++++++++----- src/gateway/server.ts | 4 ++-- 9 files changed, 41 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd4368499..2e465db48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ ### Fixes - Chat UI: keep the chat scrolled to the latest message after switching sessions. +- CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access. - Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android). - Control UI: refine Web Chat session selector styling (chevron spacing + background). - WebChat: stream live updates for sessions even when runs start outside the chat UI. diff --git a/docs/configuration.md b/docs/configuration.md index 5a90a0a53..7d7f30964 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -555,7 +555,7 @@ Defaults: mode: "local", // or "remote" bind: "loopback", // controlUi: { enabled: true } - // auth: { mode: "token" | "password" } + // auth: { mode: "token", token: "your-token" } // token is for multi-machine CLI access // tailscale: { mode: "off" | "serve" | "funnel" } } } @@ -566,6 +566,7 @@ Notes: Auth and Tailscale: - `gateway.auth.mode` sets the handshake requirements (`token` or `password`). +- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine). - When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers). - `gateway.auth.password` can be set here, or via `CLAWDIS_GATEWAY_PASSWORD` (recommended). - `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth. diff --git a/docs/onboarding.md b/docs/onboarding.md index ee11f6dcf..f47d786e3 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -22,6 +22,10 @@ First question: where does the **Gateway** run? - **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write the Clawdis token store locally. - **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**. +Gateway auth tip: +- If you only use Clawdis on this Mac (loopback gateway), keep auth **Off**. +- Use **Token** for multi-machine access or non-loopback binds. + Implementation note (2025-12-19): in local mode, the macOS app bundles the Gateway and enables it via a per-user launchd LaunchAgent (no global npm install/Node requirement for the user). ## 2) Local-only: Connect Claude (Anthropic OAuth) diff --git a/docs/wizard.md b/docs/wizard.md index 3e319fd6d..2b84c6ef7 100644 --- a/docs/wizard.md +++ b/docs/wizard.md @@ -58,6 +58,7 @@ It does **not** install or change anything on the remote host. 4) **Gateway** - Port, bind, auth mode, tailscale exposure. + - Auth recommendation: keep **Off** for single-machine loopback setups. Use **Token** for multi-machine access or non-loopback binds. - Non‑loopback binds require auth. 5) **Providers** diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index 39f9c4a53..3edf022bc 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -280,8 +280,16 @@ export async function runInteractiveOnboarding( await select({ message: "Gateway auth", options: [ - { value: "off", label: "Off (loopback only)" }, - { value: "token", label: "Token" }, + { + value: "off", + label: "Off (loopback only)", + hint: "Recommended for single-machine setups", + }, + { + value: "token", + label: "Token", + hint: "Use for multi-machine access or non-loopback binds", + }, { value: "password", label: "Password" }, ], }), @@ -344,6 +352,7 @@ export async function runInteractiveOnboarding( const tokenInput = guardCancel( await text({ message: "Gateway token (blank to generate)", + placeholder: "Needed for multi-machine or non-loopback access", initialValue: randomToken(), }), runtime, @@ -375,7 +384,11 @@ export async function runInteractiveOnboarding( ...nextConfig, gateway: { ...nextConfig.gateway, - auth: { ...nextConfig.gateway?.auth, mode: "token" }, + auth: { + ...nextConfig.gateway?.auth, + mode: "token", + token: gatewayToken, + }, }, }; } diff --git a/src/config/config.ts b/src/config/config.ts index 3525ebcf7..cd979570b 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -351,6 +351,8 @@ export type GatewayAuthMode = "token" | "password"; export type GatewayAuthConfig = { /** Authentication mode for Gateway connections. Defaults to token when set. */ mode?: GatewayAuthMode; + /** Shared token for token mode (stored locally for CLI auth). */ + token?: string; /** Shared password for password mode (consider env instead). */ password?: string; /** Allow Tailscale identity headers when serve mode is enabled. */ @@ -1097,6 +1099,7 @@ const ClawdisSchema = z.object({ auth: z .object({ mode: z.union([z.literal("token"), z.literal("password")]).optional(), + token: z.string().optional(), password: z.string().optional(), allowTailscale: z.boolean().optional(), }) diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index cb179e7d5..b2ec9324e 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -101,7 +101,7 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean { export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void { if (auth.mode === "token" && !auth.token) { throw new Error( - "gateway auth mode is token, but CLAWDIS_GATEWAY_TOKEN is not set", + "gateway auth mode is token, but no token was configured (set gateway.auth.token or CLAWDIS_GATEWAY_TOKEN)", ); } if (auth.mode === "password" && !auth.password) { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 4fc92e303..599638b4e 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -25,8 +25,8 @@ export async function callGateway( ): Promise { const timeoutMs = opts.timeoutMs ?? 10_000; const config = loadConfig(); - const remote = - config.gateway?.mode === "remote" ? config.gateway.remote : undefined; + const isRemoteMode = config.gateway?.mode === "remote"; + const remote = isRemoteMode ? config.gateway.remote : undefined; const url = (typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() @@ -39,9 +39,15 @@ export async function callGateway( (typeof opts.token === "string" && opts.token.trim().length > 0 ? opts.token.trim() : undefined) || - (typeof remote?.token === "string" && remote.token.trim().length > 0 - ? remote.token.trim() - : undefined); + (isRemoteMode + ? typeof remote?.token === "string" && remote.token.trim().length > 0 + ? remote.token.trim() + : undefined + : process.env.CLAWDIS_GATEWAY_TOKEN?.trim() || + (typeof config.gateway?.auth?.token === "string" && + config.gateway.auth.token.trim().length > 0 + ? config.gateway.auth.token.trim() + : undefined)); const password = (typeof opts.password === "string" && opts.password.trim().length > 0 ? opts.password.trim() diff --git a/src/gateway/server.ts b/src/gateway/server.ts index d29974391..e3b89bf2e 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -660,7 +660,6 @@ type DedupeEntry = { error?: ErrorShape; }; -const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN; function formatForLog(value: unknown): string { try { @@ -1371,7 +1370,8 @@ export async function startGatewayServer( ...tailscaleOverrides, }; const tailscaleMode = tailscaleConfig.mode ?? "off"; - const token = getGatewayToken(); + const token = + authConfig.token ?? process.env.CLAWDIS_GATEWAY_TOKEN ?? undefined; const password = authConfig.password ?? process.env.CLAWDIS_GATEWAY_PASSWORD ?? undefined; const authMode: ResolvedGatewayAuth["mode"] = From f57f89240987ec2749a7115493ec60687eb0f285 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:48:19 +0100 Subject: [PATCH 16/44] fix(macos): clarify gateway error state --- CHANGELOG.md | 2 + .../Sources/Clawdis/ControlChannel.swift | 42 ++++++++++++++++- apps/macos/Sources/Clawdis/HealthStore.swift | 13 ++++- .../Sources/Clawdis/MenuContentView.swift | 4 ++ .../Clawdis/MenuSessionsInjector.swift | 47 ++----------------- 5 files changed, 64 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e465db48..a7f140abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,8 @@ - CLI onboarding: always prompt for WhatsApp `whatsapp.allowFrom` and print (optionally open) the Control UI URL when done. - CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode). - macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states. +- macOS menu: show multi-line gateway error details, avoid duplicate gateway status rows, and auto-recover the control channel on disconnect. +- macOS: log health refresh failures and recovery to make gateway issues easier to diagnose. - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b - macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b - macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 87eb6847a..8a8c31e8e 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -63,9 +63,11 @@ final class ControlChannel { self.logger.info("control channel state -> connecting") case .disconnected: self.logger.info("control channel state -> disconnected") + self.scheduleRecovery(reason: "disconnected") case let .degraded(message): let detail = message.isEmpty ? "degraded" : "degraded: \(message)" self.logger.info("control channel state -> \(detail, privacy: .public)") + self.scheduleRecovery(reason: message) } } } @@ -74,6 +76,8 @@ final class ControlChannel { private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control") private var eventTask: Task? + private var recoveryTask: Task? + private var lastRecoveryAt: Date? private init() { self.startEventStream() @@ -231,7 +235,43 @@ final class ControlChannel { } let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription - return "Gateway error: \(detail)" + let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("gateway error:") { return trimmed } + return "Gateway error: \(trimmed)" + } + + private func scheduleRecovery(reason: String) { + let now = Date() + if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return } + guard self.recoveryTask == nil else { return } + self.lastRecoveryAt = now + + self.recoveryTask = Task { [weak self] in + guard let self else { return } + let mode = await MainActor.run { AppStateStore.shared.connectionMode } + guard mode != .unconfigured else { + self.recoveryTask = nil + return + } + + let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) + let reasonText = trimmedReason.isEmpty ? "unknown" : trimmedReason + self.logger.info( + "control channel recovery starting mode=\(String(describing: mode), privacy: .public) reason=\(reasonText, privacy: .public)") + if mode == .local { + GatewayProcessManager.shared.setActive(true) + } + + do { + try await GatewayConnection.shared.refresh() + self.logger.info("control channel recovery finished") + } catch { + self.logger.error( + "control channel recovery failed \(error.localizedDescription, privacy: .public)") + } + + self.recoveryTask = nil + } } func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws { diff --git a/apps/macos/Sources/Clawdis/HealthStore.swift b/apps/macos/Sources/Clawdis/HealthStore.swift index a5ae9cbe4..117cde4e7 100644 --- a/apps/macos/Sources/Clawdis/HealthStore.swift +++ b/apps/macos/Sources/Clawdis/HealthStore.swift @@ -114,6 +114,7 @@ final class HealthStore { guard !self.isRefreshing else { return } self.isRefreshing = true defer { self.isRefreshing = false } + let previousError = self.lastError do { let data = try await ControlChannel.shared.health(timeout: 15) @@ -121,13 +122,23 @@ final class HealthStore { self.snapshot = decoded self.lastSuccess = Date() self.lastError = nil + if previousError != nil { + Self.logger.info("health refresh recovered") + } } else { self.lastError = "health output not JSON" if onDemand { self.snapshot = nil } + if previousError != self.lastError { + Self.logger.warning("health refresh failed: output not JSON") + } } } catch { - self.lastError = error.localizedDescription + let desc = error.localizedDescription + self.lastError = desc if onDemand { self.snapshot = nil } + if previousError != desc { + Self.logger.error("health refresh failed \(desc, privacy: .public)") + } } } diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 5ac5a37af..4dfc30fa4 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -365,6 +365,10 @@ struct MenuContent: View { Text(label) .font(.caption) .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) } .padding(.top, 2) } diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift index f9509aae9..fd3fb7e69 100644 --- a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -106,13 +106,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { guard let insertIndex = self.findInsertIndex(in: menu) else { return } let width = self.initialWidth(for: menu) - guard self.isControlChannelConnected else { - menu.insertItem(self.makeMessageItem( - text: self.controlChannelStatusText, - symbolName: "wifi.slash", - width: width), at: insertIndex) - return - } + guard self.isControlChannelConnected else { return } guard let snapshot = self.cachedSnapshot else { let headerItem = NSMenuItem() @@ -195,16 +189,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { menu.insertItem(topSeparator, at: cursor) cursor += 1 - guard self.isControlChannelConnected else { - menu.insertItem( - self.makeMessageItem(text: self.controlChannelStatusText, symbolName: "wifi.slash", width: width), - at: cursor) - cursor += 1 - let separator = NSMenuItem.separator() - separator.tag = self.nodesTag - menu.insertItem(separator, at: cursor) - return - } + guard self.isControlChannelConnected else { return } if let error = self.nodesStore.lastError?.nonEmpty { menu.insertItem( @@ -265,36 +250,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return false } - private var controlChannelStatusText: String { - switch ControlChannel.shared.state { - case .connected: - return "Connected" - case .connecting: - return "Connecting to gateway…" - case let .degraded(reason): - if self.shouldShowConnecting { return "Connecting to gateway…" } - return reason.nonEmpty ?? "No connection to gateway" - case .disconnected: - return self.shouldShowConnecting ? "Connecting to gateway…" : "No connection to gateway" - } - } - - private var shouldShowConnecting: Bool { - switch GatewayProcessManager.shared.status { - case .starting, .running, .attachedExisting: - return true - case .stopped, .failed: - return false - } - } - private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem { let view = AnyView( Label(text, systemImage: symbolName) .font(.caption) .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) .padding(.leading, 18) .padding(.trailing, 12) .padding(.vertical, 6) From ad9d6f616db58cacdd36e6436ebad08a8a0d8f31 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:03:38 +0100 Subject: [PATCH 17/44] fix: improve onboarding auth UX --- CHANGELOG.md | 2 ++ src/agents/system-prompt.test.ts | 2 +- src/agents/system-prompt.ts | 4 ++-- src/commands/onboard-interactive.ts | 21 +++++++++++++++++---- ui/src/ui/app.ts | 15 +++++++++++++++ 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7f140abc..5fd95fc08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ ### Fixes - Chat UI: keep the chat scrolled to the latest message after switching sessions. - CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access. +- Control UI: accept a `?token=` URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured. +- Agent prompt: remove hardcoded user name in system prompt example. - Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android). - Control UI: refine Web Chat session selector styling (chevron spacing + background). - WebChat: stream live updates for sessions even when runs start outside the chat UI. diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 18d896dc0..9b4502021 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -10,7 +10,7 @@ describe("buildAgentSystemPromptAppend", () => { expect(prompt).toContain("## User Identity"); expect(prompt).toContain( - "Owner numbers: +123, +456. Treat messages from these numbers as the user (Peter).", + "Owner numbers: +123, +456. Treat messages from these numbers as the user.", ); }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 3eed242a5..796cb763b 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -25,7 +25,7 @@ export function buildAgentSystemPromptAppend(params: { .filter(Boolean); const ownerLine = ownerNumbers.length > 0 - ? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user (Peter).` + ? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user.` : undefined; const reasoningHint = params.reasoningTagHint ? [ @@ -36,7 +36,7 @@ export function buildAgentSystemPromptAppend(params: { "Only text inside is shown to the user; everything else is discarded and never seen by the user.", "Example:", "Short internal reasoning.", - "Hey Peter! What would you like to do next?", + "Hey there! What would you like to do next?", ].join(" ") : undefined; const runtimeInfo = params.runtimeInfo; diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index 3edf022bc..4b14de7f3 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -495,9 +495,18 @@ export async function runInteractiveOnboarding( note( (() => { const links = resolveControlUiLinks({ bind, port }); - return [`Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`].join( - "\n", - ); + const tokenParam = + authMode === "token" && gatewayToken + ? `?token=${encodeURIComponent(gatewayToken)}` + : ""; + const authedUrl = `${links.httpUrl}${tokenParam}`; + return [ + `Web UI: ${links.httpUrl}`, + tokenParam ? `Web UI (with token): ${authedUrl}` : undefined, + `Gateway WS: ${links.wsUrl}`, + ] + .filter(Boolean) + .join("\n"); })(), "Control UI", ); @@ -511,7 +520,11 @@ export async function runInteractiveOnboarding( ); if (wantsOpen) { const links = resolveControlUiLinks({ bind, port }); - await openUrl(links.httpUrl); + const tokenParam = + authMode === "token" && gatewayToken + ? `?token=${encodeURIComponent(gatewayToken)}` + : ""; + await openUrl(`${links.httpUrl}${tokenParam}`); } outro("Onboarding complete."); diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index ffac48fcc..3c142e8e8 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -186,6 +186,7 @@ export class ClawdisApp extends LitElement { this.syncThemeWithSettings(); this.attachThemeListener(); window.addEventListener("popstate", this.popStateHandler); + this.applySettingsFromUrl(); this.connect(); this.startNodesPolling(); } @@ -334,6 +335,20 @@ export class ClawdisApp extends LitElement { } } + private applySettingsFromUrl() { + if (!window.location.search) return; + const params = new URLSearchParams(window.location.search); + const token = params.get("token")?.trim(); + if (!token) return; + if (!this.settings.token) { + this.applySettings({ ...this.settings, token }); + } + params.delete("token"); + const url = new URL(window.location.href); + url.search = params.toString(); + window.history.replaceState({}, "", url.toString()); + } + setTab(next: Tab) { if (this.tab !== next) this.tab = next; void this.refreshActiveTab(); From 87be5c737cd9e14c98023553ae6d01f97c7561cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:12:57 +0100 Subject: [PATCH 18/44] fix(macos): suppress cancelled node refresh --- CHANGELOG.md | 2 +- apps/macos/Sources/Clawdis/NodesStore.swift | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd95fc08..8998ded54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,7 @@ - CLI onboarding: always prompt for WhatsApp `whatsapp.allowFrom` and print (optionally open) the Control UI URL when done. - CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode). - macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states. -- macOS menu: show multi-line gateway error details, avoid duplicate gateway status rows, and auto-recover the control channel on disconnect. +- macOS menu: show multi-line gateway error details, avoid duplicate gateway status rows, suppress transient `cancelled` device refresh errors, and auto-recover the control channel on disconnect. - macOS: log health refresh failures and recovery to make gateway issues easier to diagnose. - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b - macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b diff --git a/apps/macos/Sources/Clawdis/NodesStore.swift b/apps/macos/Sources/Clawdis/NodesStore.swift index 2c00e15f7..cc647e102 100644 --- a/apps/macos/Sources/Clawdis/NodesStore.swift +++ b/apps/macos/Sources/Clawdis/NodesStore.swift @@ -75,10 +75,26 @@ final class NodesStore { self.lastError = nil self.statusMessage = nil } catch { + if Self.isCancelled(error) { + self.logger.debug("node.list cancelled; keeping last nodes") + if self.nodes.isEmpty { + self.statusMessage = "Refreshing devices…" + } + self.lastError = nil + return + } self.logger.error("node.list failed \(error.localizedDescription, privacy: .public)") self.nodes = [] self.lastError = error.localizedDescription self.statusMessage = nil } } + + private static func isCancelled(_ error: Error) -> Bool { + if error is CancellationError { return true } + if let urlError = error as? URLError, urlError.code == .cancelled { return true } + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return true } + return false + } } From c93d02891a23aa478cf2dd7f7644f217771627ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:13:05 +0100 Subject: [PATCH 19/44] test: cover control ui token url --- ui/src/ui/navigation.browser.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index da9d34b38..43f182e3f 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -90,4 +90,13 @@ describe("control UI routing", () => { expect(maxScroll).toBeGreaterThan(0); expect(container.scrollTop).toBe(maxScroll); }); + + it("hydrates token from URL params and strips it", async () => { + const app = mountApp("/ui/overview?token=abc123"); + await app.updateComplete; + + expect(app.settings.token).toBe("abc123"); + expect(window.location.pathname).toBe("/ui/overview"); + expect(window.location.search).toBe(""); + }); }); From ebf86499406f37e88b39e9e943098ac152a15568 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:22:23 +0100 Subject: [PATCH 20/44] feat: add songsee skill --- skills/songsee/SKILL.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 skills/songsee/SKILL.md diff --git a/skills/songsee/SKILL.md b/skills/songsee/SKILL.md new file mode 100644 index 000000000..8141113c2 --- /dev/null +++ b/skills/songsee/SKILL.md @@ -0,0 +1,29 @@ +--- +name: songsee +description: Generate spectrograms and feature-panel visualizations from audio with the songsee CLI. +homepage: https://github.com/steipete/songsee +metadata: {"clawdis":{"emoji":"🌊","requires":{"bins":["songsee"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/songsee","bins":["songsee"],"label":"Install songsee (brew)"}]}} +--- + +# songsee + +Generate spectrograms + feature panels from audio. + +Quick start +- Spectrogram: `songsee track.mp3` +- Multi-panel: `songsee track.mp3 --viz spectrogram,mel,chroma,hpss,selfsim,loudness,tempogram,mfcc,flux` +- Time slice: `songsee track.mp3 --start 12.5 --duration 8 -o slice.jpg` +- Stdin: `cat track.mp3 | songsee - --format png -o out.png` + +Common flags +- `--viz` list (repeatable or comma-separated) +- `--style` palette (classic, magma, inferno, viridis, gray) +- `--width` / `--height` output size +- `--window` / `--hop` FFT settings +- `--min-freq` / `--max-freq` frequency range +- `--start` / `--duration` time slice +- `--format` jpg|png + +Notes +- WAV/MP3 decode native; other formats use ffmpeg if available. +- Multiple `--viz` renders a grid. From 68806902ff31b9bee18031723208c672ca9e816b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:27:21 +0100 Subject: [PATCH 21/44] fix(macos): show gateway in devices list --- CHANGELOG.md | 2 +- .../Clawdis/MenuSessionsInjector.swift | 68 ++++++++++++++++--- apps/macos/Sources/Clawdis/NodesMenu.swift | 20 ++++++ 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8998ded54..24940c720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,7 @@ - CLI onboarding: always prompt for WhatsApp `whatsapp.allowFrom` and print (optionally open) the Control UI URL when done. - CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode). - macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states. -- macOS menu: show multi-line gateway error details, avoid duplicate gateway status rows, suppress transient `cancelled` device refresh errors, and auto-recover the control channel on disconnect. +- macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient `cancelled` device refresh errors, and auto-recover the control channel on disconnect. - macOS: log health refresh failures and recovery to make gateway issues easier to diagnose. - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b - macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift index fd3fb7e69..312744219 100644 --- a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -189,6 +189,12 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { menu.insertItem(topSeparator, at: cursor) cursor += 1 + if let gatewayEntry = self.gatewayEntry() { + let gatewayItem = self.makeNodeItem(entry: gatewayEntry, width: width) + menu.insertItem(gatewayItem, at: cursor) + cursor += 1 + } + guard self.isControlChannelConnected else { return } if let error = self.nodesStore.lastError?.nonEmpty { @@ -214,15 +220,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { cursor += 1 } else { for entry in entries.prefix(8) { - let item = NSMenuItem() - item.tag = self.nodesTag - item.target = self - item.action = #selector(self.copyNodeSummary(_:)) - item.representedObject = NodeMenuEntryFormatter.summaryText(entry) - item.view = HighlightedMenuItemHostView( - rootView: AnyView(NodeMenuRowView(entry: entry, width: width)), - width: width) - item.submenu = self.buildNodeSubmenu(entry: entry, width: width) + let item = self.makeNodeItem(entry: entry, width: width) menu.insertItem(item, at: cursor) cursor += 1 } @@ -250,6 +248,58 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return false } + private func gatewayEntry() -> NodeInfo? { + let mode = AppStateStore.shared.connectionMode + let isConnected = self.isControlChannelConnected + let port = GatewayEnvironment.gatewayPort() + var host: String? + var platform: String? + + switch mode { + case .remote: + platform = "remote" + let target = AppStateStore.shared.remoteTarget + if let parsed = CommandResolver.parseSSHTarget(target) { + host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)" + } else { + host = target.nonEmpty + } + case .local: + platform = "local" + host = "127.0.0.1:\(port)" + case .unconfigured: + platform = nil + host = nil + } + + return NodeInfo( + nodeId: "gateway", + displayName: "Gateway", + platform: platform, + version: nil, + deviceFamily: nil, + modelIdentifier: nil, + remoteIp: host, + caps: nil, + commands: nil, + permissions: nil, + paired: nil, + connected: isConnected) + } + + private func makeNodeItem(entry: NodeInfo, width: CGFloat) -> NSMenuItem { + let item = NSMenuItem() + item.tag = self.nodesTag + item.target = self + item.action = #selector(self.copyNodeSummary(_:)) + item.representedObject = NodeMenuEntryFormatter.summaryText(entry) + item.view = HighlightedMenuItemHostView( + rootView: AnyView(NodeMenuRowView(entry: entry, width: width)), + width: width) + item.submenu = self.buildNodeSubmenu(entry: entry, width: width) + return item + } + private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem { let view = AnyView( Label(text, systemImage: symbolName) diff --git a/apps/macos/Sources/Clawdis/NodesMenu.swift b/apps/macos/Sources/Clawdis/NodesMenu.swift index 882b7ec3e..792bb01c1 100644 --- a/apps/macos/Sources/Clawdis/NodesMenu.swift +++ b/apps/macos/Sources/Clawdis/NodesMenu.swift @@ -2,15 +2,30 @@ import AppKit import SwiftUI struct NodeMenuEntryFormatter { + static func isGateway(_ entry: NodeInfo) -> Bool { + entry.nodeId == "gateway" + } + static func isConnected(_ entry: NodeInfo) -> Bool { entry.isConnected } static func primaryName(_ entry: NodeInfo) -> String { + if self.isGateway(entry) { + return entry.displayName?.nonEmpty ?? "Gateway" + } entry.displayName?.nonEmpty ?? entry.nodeId } static func summaryText(_ entry: NodeInfo) -> String { + if self.isGateway(entry) { + let role = self.roleText(entry) + let name = self.primaryName(entry) + var parts = ["\(name) · \(role)"] + if let ip = entry.remoteIp?.nonEmpty { parts.append("host \(ip)") } + if let platform = self.platformText(entry) { parts.append(platform) } + return parts.joined(separator: " · ") + } let name = self.primaryName(entry) var prefix = "Node: \(name)" if let ip = entry.remoteIp?.nonEmpty { @@ -112,6 +127,11 @@ struct NodeMenuEntryFormatter { } static func leadingSymbol(_ entry: NodeInfo) -> String { + if self.isGateway(entry) { + return self.safeSystemSymbol( + "antenna.radiowaves.left.and.right", + fallback: "dot.radiowaves.left.and.right") + } if let family = entry.deviceFamily?.lowercased() { if family.contains("mac") { return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") From 25e52e19dcadd0e009e6016349c9e2e50b9a499d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:28:34 +0100 Subject: [PATCH 22/44] fix(macos): return node name --- apps/macos/Sources/Clawdis/NodesMenu.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/macos/Sources/Clawdis/NodesMenu.swift b/apps/macos/Sources/Clawdis/NodesMenu.swift index 792bb01c1..70635929b 100644 --- a/apps/macos/Sources/Clawdis/NodesMenu.swift +++ b/apps/macos/Sources/Clawdis/NodesMenu.swift @@ -14,7 +14,7 @@ struct NodeMenuEntryFormatter { if self.isGateway(entry) { return entry.displayName?.nonEmpty ?? "Gateway" } - entry.displayName?.nonEmpty ?? entry.nodeId + return entry.displayName?.nonEmpty ?? entry.nodeId } static func summaryText(_ entry: NodeInfo) -> String { From 9b3aef3567391a69d588ff4f7128a023b81a9cba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 16:25:28 +0100 Subject: [PATCH 23/44] fix: show skill descriptions in onboarding list --- src/commands/onboard-skills.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/commands/onboard-skills.ts b/src/commands/onboard-skills.ts index ce99b3142..5365625c1 100644 --- a/src/commands/onboard-skills.ts +++ b/src/commands/onboard-skills.ts @@ -22,6 +22,21 @@ function summarizeInstallFailure(message: string): string | undefined { return cleaned.length > maxLen ? `${cleaned.slice(0, maxLen - 1)}…` : cleaned; } +function formatSkillHint(skill: { + description?: string; + install: Array<{ label: string }>; +}): string { + const desc = skill.description?.trim(); + const installLabel = skill.install[0]?.label?.trim(); + const combined = + desc && installLabel ? `${desc} — ${installLabel}` : desc || installLabel; + if (!combined) return "install"; + const maxLen = 90; + return combined.length > maxLen + ? `${combined.slice(0, maxLen - 1)}…` + : combined; +} + function upsertSkillEntry( cfg: ClawdisConfig, skillKey: string, @@ -104,7 +119,7 @@ export async function setupSkills( ...installable.map((skill) => ({ value: skill.name, label: `${skill.emoji ?? "đź§©"} ${skill.name}`, - hint: skill.install[0]?.label ?? "install", + hint: formatSkillHint(skill), })), ], }), From 8de40e0c10255786adf10082b1ac6beb0df31ccb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:27:33 +0000 Subject: [PATCH 24/44] feat(macos): add Camera permission to onboarding flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'camera' case to Capability enum - Add UI strings (title, subtitle, icon) in PermissionsSettings - Add ensureCamera() and camera status check in PermissionManager - Add CameraPermissionHelper for opening System Settings 🦞 Clawd's first code contribution! --- .../Sources/Clawdis/PermissionManager.swift | 38 +++++++++++++++++++ .../Sources/Clawdis/PermissionsSettings.swift | 3 ++ apps/macos/Sources/ClawdisIPC/IPC.swift | 1 + 3 files changed, 42 insertions(+) diff --git a/apps/macos/Sources/Clawdis/PermissionManager.swift b/apps/macos/Sources/Clawdis/PermissionManager.swift index e6de4e58f..8afb9745f 100644 --- a/apps/macos/Sources/Clawdis/PermissionManager.swift +++ b/apps/macos/Sources/Clawdis/PermissionManager.swift @@ -31,6 +31,8 @@ enum PermissionManager { await self.ensureMicrophone(interactive: interactive) case .speechRecognition: await self.ensureSpeechRecognition(interactive: interactive) + case .camera: + await self.ensureCamera(interactive: interactive) } } @@ -114,6 +116,24 @@ enum PermissionManager { return SFSpeechRecognizer.authorizationStatus() == .authorized } + private static func ensureCamera(interactive: Bool) async -> Bool { + let status = AVCaptureDevice.authorizationStatus(for: .video) + switch status { + case .authorized: + return true + case .notDetermined: + guard interactive else { return false } + return await AVCaptureDevice.requestAccess(for: .video) + case .denied, .restricted: + if interactive { + CameraPermissionHelper.openSettings() + } + return false + @unknown default: + return false + } + } + static func voiceWakePermissionsGranted() -> Bool { let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized let speech = SFSpeechRecognizer.authorizationStatus() == .authorized @@ -153,6 +173,9 @@ enum PermissionManager { case .speechRecognition: results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized + + case .camera: + results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized } } return results @@ -189,6 +212,21 @@ enum MicrophonePermissionHelper { } } +enum CameraPermissionHelper { + static func openSettings() { + let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera", + "x-apple.systempreferences:com.apple.preference.security", + ] + + for candidate in candidates { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + return + } + } + } +} + enum AppleScriptPermission { private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "AppleScriptPermission") diff --git a/apps/macos/Sources/Clawdis/PermissionsSettings.swift b/apps/macos/Sources/Clawdis/PermissionsSettings.swift index fd2040f22..955e17edb 100644 --- a/apps/macos/Sources/Clawdis/PermissionsSettings.swift +++ b/apps/macos/Sources/Clawdis/PermissionsSettings.swift @@ -120,6 +120,7 @@ struct PermissionRow: View { case .screenRecording: "Screen Recording" case .microphone: "Microphone" case .speechRecognition: "Speech Recognition" + case .camera: "Camera" } } @@ -132,6 +133,7 @@ struct PermissionRow: View { case .screenRecording: "Capture the screen for context or screenshots" case .microphone: "Allow Voice Wake and audio capture" case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device" + case .camera: "Capture photos and video from the camera" } } @@ -143,6 +145,7 @@ struct PermissionRow: View { case .screenRecording: "display" case .microphone: "mic" case .speechRecognition: "waveform" + case .camera: "camera" } } } diff --git a/apps/macos/Sources/ClawdisIPC/IPC.swift b/apps/macos/Sources/ClawdisIPC/IPC.swift index 469d80405..0a7bea442 100644 --- a/apps/macos/Sources/ClawdisIPC/IPC.swift +++ b/apps/macos/Sources/ClawdisIPC/IPC.swift @@ -11,6 +11,7 @@ public enum Capability: String, Codable, CaseIterable, Sendable { case screenRecording case microphone case speechRecognition + case camera } public enum CameraFacing: String, Codable, Sendable { From 6b7484a8856b88266237138761585815112f162f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:46:08 +0000 Subject: [PATCH 25/44] feat(skills): add local-places skill for Google Places search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wraps Hyaxia/local_places FastAPI server - Two-step flow: resolve location → search places - Supports filters: type, rating, price, open_now 🦞 --- skills/local-places/SKILL.md | 87 ++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 skills/local-places/SKILL.md diff --git a/skills/local-places/SKILL.md b/skills/local-places/SKILL.md new file mode 100644 index 000000000..eefb74184 --- /dev/null +++ b/skills/local-places/SKILL.md @@ -0,0 +1,87 @@ +--- +name: local-places +description: Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost. +homepage: https://github.com/Hyaxia/local_places +metadata: {"clawdis":{"emoji":"📍","requires":{"bins":["uv"],"env":["GOOGLE_PLACES_API_KEY"]},"primaryEnv":"GOOGLE_PLACES_API_KEY"}} +--- + +# Local Places + +Search for nearby places using a local Google Places API proxy. Two-step flow: resolve location first, then search. + +## Setup + +```bash +cd ~/Projects/local_places +uv run --env-file .env uvicorn local_places.main:app --host 127.0.0.1 --port 8000 +``` + +Requires `GOOGLE_PLACES_API_KEY` in `.env` or environment. + +## Quick Start + +1. **Check server:** `curl http://127.0.0.1:8000/ping` + +2. **Resolve location:** +```bash +curl -X POST http://127.0.0.1:8000/locations/resolve \ + -H "Content-Type: application/json" \ + -d '{"location_text": "Soho, London", "limit": 5}' +``` + +3. **Search places:** +```bash +curl -X POST http://127.0.0.1:8000/places/search \ + -H "Content-Type: application/json" \ + -d '{ + "query": "coffee shop", + "location_bias": {"lat": 51.5137, "lng": -0.1366, "radius_m": 1000}, + "filters": {"open_now": true, "min_rating": 4.0}, + "limit": 10 + }' +``` + +4. **Get details:** +```bash +curl http://127.0.0.1:8000/places/{place_id} +``` + +## Conversation Flow + +1. If user says "near me" or gives vague location → resolve it first +2. If multiple results → show numbered list, ask user to pick +3. Ask for preferences: type, open now, rating, price level +4. Search with `location_bias` from chosen location +5. Present results with name, rating, address, open status +6. Offer to fetch details or refine search + +## Filter Constraints + +- `filters.types`: exactly ONE type (e.g., "restaurant", "cafe", "gym") +- `filters.price_levels`: integers 0-4 (0=free, 4=very expensive) +- `filters.min_rating`: 0-5 in 0.5 increments +- `filters.open_now`: boolean +- `limit`: 1-20 for search, 1-10 for resolve +- `location_bias.radius_m`: must be > 0 + +## Response Format + +```json +{ + "results": [ + { + "place_id": "ChIJ...", + "name": "Coffee Shop", + "address": "123 Main St", + "location": {"lat": 51.5, "lng": -0.1}, + "rating": 4.6, + "price_level": 2, + "types": ["cafe", "food"], + "open_now": true + } + ], + "next_page_token": "..." +} +``` + +Use `next_page_token` as `page_token` in next request for more results. From 100a022ab76eb4ba7c67e7e7142fa1d4d0ddff16 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:47:42 +0000 Subject: [PATCH 26/44] feat(skills/local-places): add server as submodule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Links to Hyaxia/local_places for easy upstream updates - Updated SKILL.md with {baseDir}/server path 🦞 --- .gitmodules | 3 +++ skills/local-places/SKILL.md | 5 ++++- skills/local-places/server | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) create mode 160000 skills/local-places/server diff --git a/.gitmodules b/.gitmodules index 096e18c88..673aa118c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,6 @@ path = Peekaboo url = https://github.com/steipete/Peekaboo.git branch = main +[submodule "skills/local-places/server"] + path = skills/local-places/server + url = https://github.com/Hyaxia/local_places.git diff --git a/skills/local-places/SKILL.md b/skills/local-places/SKILL.md index eefb74184..8e62f4cef 100644 --- a/skills/local-places/SKILL.md +++ b/skills/local-places/SKILL.md @@ -12,11 +12,14 @@ Search for nearby places using a local Google Places API proxy. Two-step flow: r ## Setup ```bash -cd ~/Projects/local_places +cd {baseDir}/server +echo "GOOGLE_PLACES_API_KEY=your-key" > .env +uv venv && uv pip install -e ".[dev]" uv run --env-file .env uvicorn local_places.main:app --host 127.0.0.1 --port 8000 ``` Requires `GOOGLE_PLACES_API_KEY` in `.env` or environment. +Server code is in `{baseDir}/server/` (submodule from Hyaxia/local_places). ## Quick Start diff --git a/skills/local-places/server b/skills/local-places/server new file mode 160000 index 000000000..bfc3becfc --- /dev/null +++ b/skills/local-places/server @@ -0,0 +1 @@ +Subproject commit bfc3becfc48af865722ef35ee7cca753519dd93e From a8bc974a2e98f4a4850b2f48856e1cefd9b05c45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 16:47:52 +0100 Subject: [PATCH 27/44] fix: harden gateway password auth --- .../Clawdis/GatewayEndpointStore.swift | 25 +++++++++---------- .../Clawdis/GatewayLaunchAgentManager.swift | 15 +++++++++-- .../GatewayEndpointStoreTests.swift | 7 +++++- src/gateway/call.ts | 2 +- src/gateway/hooks-mapping.ts | 18 ++++++++++--- 5 files changed, 47 insertions(+), 20 deletions(-) diff --git a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift index 079fdc8e7..7be78c407 100644 --- a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift @@ -26,15 +26,24 @@ actor GatewayEndpointStore { mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] }, password: { - // First check environment variable let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PASSWORD"] ?? "" let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } - // Then check config file based on connection mode let root = ClawdisConfigFile.loadDict() - // Check gateway.auth.password (for local gateway auth) + if CommandResolver.connectionModeIsRemote() { + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let password = remote["password"] as? String + { + let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) + if !pw.isEmpty { + return pw + } + } + return nil + } if let gateway = root["gateway"] as? [String: Any], let auth = gateway["auth"] as? [String: Any], let password = auth["password"] as? String @@ -44,16 +53,6 @@ actor GatewayEndpointStore { return pw } } - // Check gateway.remote.password (for remote gateway auth) - if let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let password = remote["password"] as? String - { - let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) - if !pw.isEmpty { - return pw - } - } return nil }, localPort: { GatewayEnvironment.gatewayPort() }, diff --git a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift index 9cc01ffef..7d0f04995 100644 --- a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift @@ -72,15 +72,17 @@ enum GatewayLaunchAgentManager { sips """ if let token { + let escapedToken = self.escapePlistValue(token) envEntries += """ CLAWDIS_GATEWAY_TOKEN - \(token) + \(escapedToken) """ } if let password { + let escapedPassword = self.escapePlistValue(password) envEntries += """ CLAWDIS_GATEWAY_PASSWORD - \(password) + \(escapedPassword) """ } let plist = """ @@ -171,6 +173,15 @@ enum GatewayLaunchAgentManager { return nil } + private static func escapePlistValue(_ raw: String) -> String { + raw + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } + private struct LaunchctlResult { let status: Int32 let output: String diff --git a/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift index 64f117ec2..6061fc8f9 100644 --- a/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift @@ -29,6 +29,7 @@ import Testing let store = GatewayEndpointStore(deps: .init( mode: { mode.get() }, token: { "t" }, + password: { nil }, localPort: { 1234 }, remotePortIfRunning: { nil }, ensureRemoteTunnel: { 18789 })) @@ -44,6 +45,7 @@ import Testing let store = GatewayEndpointStore(deps: .init( mode: { mode.get() }, token: { nil }, + password: { nil }, localPort: { 18789 }, remotePortIfRunning: { nil }, ensureRemoteTunnel: { 18789 })) @@ -58,6 +60,7 @@ import Testing let store = GatewayEndpointStore(deps: .init( mode: { mode.get() }, token: { "tok" }, + password: { "pw" }, localPort: { 1 }, remotePortIfRunning: { 5555 }, ensureRemoteTunnel: { 5555 })) @@ -69,13 +72,14 @@ import Testing _ = try await store.ensureRemoteControlTunnel() let next = await iterator.next() - guard case let .ready(mode, url, token) = next else { + guard case let .ready(mode, url, token, password) = next else { Issue.record("expected .ready after ensure, got \(String(describing: next))") return } #expect(mode == .remote) #expect(url.absoluteString == "ws://127.0.0.1:5555") #expect(token == "tok") + #expect(password == "pw") } @Test func unconfiguredModeRejectsConfig() async { @@ -83,6 +87,7 @@ import Testing let store = GatewayEndpointStore(deps: .init( mode: { mode.get() }, token: { nil }, + password: { nil }, localPort: { 18789 }, remotePortIfRunning: { nil }, ensureRemoteTunnel: { 18789 })) diff --git a/src/gateway/call.ts b/src/gateway/call.ts index e17f8203f..edecfa285 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -46,7 +46,7 @@ export async function callGateway( (typeof opts.password === "string" && opts.password.trim().length > 0 ? opts.password.trim() : undefined) || - (process.env.CLAWDIS_GATEWAY_PASSWORD?.trim()) || + process.env.CLAWDIS_GATEWAY_PASSWORD?.trim() || (typeof remote?.password === "string" && remote.password.trim().length > 0 ? remote.password.trim() : undefined); diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 81c001878..0b2ee8132 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -18,7 +18,13 @@ export type HookMappingResolved = { messageTemplate?: string; textTemplate?: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; + channel?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "signal" + | "imessage"; to?: string; thinking?: string; timeoutSeconds?: number; @@ -50,7 +56,13 @@ export type HookAction = wakeMode: "now" | "next-heartbeat"; sessionKey?: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; + channel?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "signal" + | "imessage"; to?: string; thinking?: string; timeoutSeconds?: number; @@ -86,7 +98,7 @@ type HookTransformResult = Partial<{ name: string; sessionKey: string; deliver: boolean; - channel: "last" | "whatsapp" | "telegram" | "discord"; + channel: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; to: string; thinking: string; timeoutSeconds: number; From 921e5be8e640c65992b17fbe721d288f56c2e40e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:48:24 +0000 Subject: [PATCH 28/44] fix(skills/local-places): copy files instead of submodule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Submodules are pain. Just copy the Python code directly. 🦞 --- .gitmodules | 3 - skills/local-places/SERVER_README.md | 101 ++++++ skills/local-places/SKILL.md | 3 +- skills/local-places/pyproject.toml | 27 ++ skills/local-places/server | 1 - .../local-places/src/local_places/__init__.py | 2 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 218 bytes .../__pycache__/google_places.cpython-314.pyc | Bin 0 -> 14487 bytes .../__pycache__/main.cpython-314.pyc | Bin 0 -> 3794 bytes .../__pycache__/schemas.cpython-314.pyc | Bin 0 -> 6290 bytes .../src/local_places/google_places.py | 314 ++++++++++++++++++ skills/local-places/src/local_places/main.py | 65 ++++ .../local-places/src/local_places/schemas.py | 107 ++++++ 13 files changed, 617 insertions(+), 6 deletions(-) create mode 100644 skills/local-places/SERVER_README.md create mode 100644 skills/local-places/pyproject.toml delete mode 160000 skills/local-places/server create mode 100644 skills/local-places/src/local_places/__init__.py create mode 100644 skills/local-places/src/local_places/__pycache__/__init__.cpython-314.pyc create mode 100644 skills/local-places/src/local_places/__pycache__/google_places.cpython-314.pyc create mode 100644 skills/local-places/src/local_places/__pycache__/main.cpython-314.pyc create mode 100644 skills/local-places/src/local_places/__pycache__/schemas.cpython-314.pyc create mode 100644 skills/local-places/src/local_places/google_places.py create mode 100644 skills/local-places/src/local_places/main.py create mode 100644 skills/local-places/src/local_places/schemas.py diff --git a/.gitmodules b/.gitmodules index 673aa118c..096e18c88 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,6 +2,3 @@ path = Peekaboo url = https://github.com/steipete/Peekaboo.git branch = main -[submodule "skills/local-places/server"] - path = skills/local-places/server - url = https://github.com/Hyaxia/local_places.git diff --git a/skills/local-places/SERVER_README.md b/skills/local-places/SERVER_README.md new file mode 100644 index 000000000..1a69931f2 --- /dev/null +++ b/skills/local-places/SERVER_README.md @@ -0,0 +1,101 @@ +# Local Places + +This repo is a fusion of two pieces: + +- A FastAPI server that exposes endpoints for searching and resolving places via the Google Maps Places API. +- A companion agent skill that explains how to use the API and can call it to find places efficiently. + +Together, the skill and server let an agent turn natural-language place queries into structured results quickly. + +## Run locally + +```bash +# copy skill definition into the relevant folder (where the agent looks for it) +# then run the server + +uv venv +uv pip install -e ".[dev]" +uv run --env-file .env uvicorn local_places.main:app --host 0.0.0.0 --reload +``` + +Open the API docs at http://127.0.0.1:8000/docs. + +## Places API + +Set the Google Places API key before running: + +```bash +export GOOGLE_PLACES_API_KEY="your-key" +``` + +Endpoints: + +- `POST /places/search` (free-text query + filters) +- `GET /places/{place_id}` (place details) +- `POST /locations/resolve` (resolve a user-provided location string) + +Example search request: + +```json +{ + "query": "italian restaurant", + "filters": { + "types": ["restaurant"], + "open_now": true, + "min_rating": 4.0, + "price_levels": [1, 2] + }, + "limit": 10 +} +``` + +Notes: + +- `filters.types` supports a single type (mapped to Google `includedType`). + +Example search request (curl): + +```bash +curl -X POST http://127.0.0.1:8000/places/search \ + -H "Content-Type: application/json" \ + -d '{ + "query": "italian restaurant", + "location_bias": { + "lat": 40.8065, + "lng": -73.9719, + "radius_m": 3000 + }, + "filters": { + "types": ["restaurant"], + "open_now": true, + "min_rating": 4.0, + "price_levels": [1, 2, 3] + }, + "limit": 10 + }' +``` + +Example resolve request (curl): + +```bash +curl -X POST http://127.0.0.1:8000/locations/resolve \ + -H "Content-Type: application/json" \ + -d '{ + "location_text": "Riverside Park, New York", + "limit": 5 + }' +``` + +## Test + +```bash +uv run pytest +``` + +## OpenAPI + +Generate the OpenAPI schema: + +```bash +uv run python scripts/generate_openapi.py +``` diff --git a/skills/local-places/SKILL.md b/skills/local-places/SKILL.md index 8e62f4cef..bc563d419 100644 --- a/skills/local-places/SKILL.md +++ b/skills/local-places/SKILL.md @@ -12,14 +12,13 @@ Search for nearby places using a local Google Places API proxy. Two-step flow: r ## Setup ```bash -cd {baseDir}/server +cd {baseDir} echo "GOOGLE_PLACES_API_KEY=your-key" > .env uv venv && uv pip install -e ".[dev]" uv run --env-file .env uvicorn local_places.main:app --host 127.0.0.1 --port 8000 ``` Requires `GOOGLE_PLACES_API_KEY` in `.env` or environment. -Server code is in `{baseDir}/server/` (submodule from Hyaxia/local_places). ## Quick Start diff --git a/skills/local-places/pyproject.toml b/skills/local-places/pyproject.toml new file mode 100644 index 000000000..c59e336a1 --- /dev/null +++ b/skills/local-places/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "my-api" +version = "0.1.0" +description = "FastAPI server" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.110.0", + "httpx>=0.27.0", + "uvicorn[standard]>=0.29.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/local_places"] + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] diff --git a/skills/local-places/server b/skills/local-places/server deleted file mode 160000 index bfc3becfc..000000000 --- a/skills/local-places/server +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfc3becfc48af865722ef35ee7cca753519dd93e diff --git a/skills/local-places/src/local_places/__init__.py b/skills/local-places/src/local_places/__init__.py new file mode 100644 index 000000000..07c5de9e2 --- /dev/null +++ b/skills/local-places/src/local_places/__init__.py @@ -0,0 +1,2 @@ +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/skills/local-places/src/local_places/__pycache__/__init__.cpython-314.pyc b/skills/local-places/src/local_places/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a17848a45a0a0a69ee91bccfa284ee891b61154 GIT binary patch literal 218 zcmdPqD`4)G4d|7Hy zab|vAe0&wFfu5nBfuAPRE%x~M#GIV?_#!5twv`N@L8jbt(hn^Ls?{$pNzE)sElJf6 zD9X=DO)e?c&&f|t%!x0^NlZ=!N*5)g3dF}}=4F<|$LkeT-r}&y%}*)KNwq6t2O0@- eU9ljL_`uA_$asTWqC>xd{RW?C6L%3SP!s^PnmC#O literal 0 HcmV?d00001 diff --git a/skills/local-places/src/local_places/__pycache__/google_places.cpython-314.pyc b/skills/local-places/src/local_places/__pycache__/google_places.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..94944facf8666a5131c4bb4a8855f5acfed649c5 GIT binary patch literal 14487 zcmcgTYiwKBdFS%-^8M0V6!oAUwoE@nH?b`{@guTLNwy`KzLJ$>kCcE8cC3{5KFht%~#K3?p1v+#on(QxSx)6J-q5%pF$d3W?EZ433XWw`3 zgS1T9?i}>kJkRf*$9KN-z0T1#kJ~{Y?EdeUlh@k``85{wKwAi5?&cUmX31H?Gsj3j zBQq_SHyty{CYrJ`3n_cd+|S9J)@G3{P-i}7?YGG`TE`u;_d8@qzf*ShyJT0tTXy$* zWKX|W_V)W^Uw?&M(L&C!(GGq1V%kc03vbx)$uCr#|9p>cPGqusM3R4>I(h5Funw(~)*f5zg^;!wJ1Y?TK%f54_6Lv!E zq?t^yStd*5dfp@Zd9U2S`{Y0kfw3#<2w!Q;v5~KWa&;X^`Q#?P2J*Eu-^|xRzMke= zct7MDXg9Myyr~`5)S}HlNkZE`$2Er-#TiehGjd!`X429Sl-Zv2G$ii+k&$TRjd3B1 zEg_p?8HmdR=?SHFATv&(c|ppgE(*Nxl_^1zmAXgjr7UzQD6VKKJ}w*;g7XK^s8Ya)}GND1+5QtBGdOolIhX{I3s^G?McYS&7NFUlV~5{V5&Mk52UUOp00 z>Pl;l4MkpwMuvuujYgE}(uTp4M(Toh7zjXa$djE~2s zL{Uh~C$mBtKI+d*iPHA8@P-_XPY5HK*M;=Vw_sAGvw371#`yZc#Szt^L}6ko6&D{G zqwKhJ8Szx~LMAN?O--H?#O;@ab5c?kPK(KzQ(q*K=*-4T&poRan7cWz&Cuw|% zAP<8VGjPB9l4p5aYl20CItM_9!MyH2~A@gRl2(WQ5L1JABS z-53eGZ&UihrzJs@!jdc`vw|#yqhjXE!niDjOG!U0iQ^AfsAok#8QE#Y6^m(1L5Rha z#&rwoDsAn9k)CE!<6Qfxev5EOe%M-jA`B_VZQtcUL<9C!0(F#w5Ti^Y=ZqzsFx ze{?Zw3tdPJ0MC*_pnW+IUJ8USn{V6d3;xzW_lV8V^8ejQu@2{85e0clOh51>lH>Ag zk}Pfy0;fGA`k^tzhz*da`$(rOB7ula5S_pV9m5Q_=?;PDYMLg+X#o%o8iCpG?tvm; zn3ThCRHJ+C8=B~}+)l(GRD?`o8zhSP%TgwNgAv=IK&Pi-RNRV<)$5Uoz;wPEBE3~} zU%T?PWpCS(w=M7O$k{rIGuHQ2oAJ{e35rOu6NhF2flox;Nt|>Mb?u0=_N(JGDNY1{ zXut^pV(fRjAhRLraJuWldX&k)OT}#fREn^jP<|9)2s(t2l@QI6&q@)Nv$fGlYFi<; z0>}_B7$D{}hzKBNM=Um(Nlc|M4`=o(Q}LA6;*G`5Cq+q4CDTGW6N`x*@UgmSwyc=R z3ZgtM(ka!%VtAU8<1vskV)ERSEJy&4R7poysS)>6#Hbg3QV^nVl6xjN>i29wjjI-7 z@vNBuxp!GTr+$o|JalZ#PytA0?qVN^xI)CgalUB5n>P zftiwn1jO+8gM_DP7=98SvsrSVI2p?wjOK?|Fos?FVGLiAXY=P3?YCT zNOTo6%ZxHesTd|a$lk{|wM0OX=1n}?t@Qv*-^_EoWt8Eqqa@72x@}6$!+K6n^jPdf z7*|uO(3W6dRlmxl!hKW*(Vo6Y~YsPgXla@i`?-&6+V8$2EW>ZO3fe7P~ zo$fgPR}5<}2=N36B(VqJDE5y)W|pjYd~?ZfCv)}Tyl3ZZ->;mNE1p21K2WIIv&OMr z$0}j%j#US-*X8QB7HswNodsLN{P9(**=1QJX3kRFCOoqk&{m<_v>h^!AuE%LYCyl0 zw}m+r*cx}96yyt;1SKzpof1=uH5;E!W#WkkH4iiF;GYGDKv$uQr-Vq)#bbF9QePZ? zk}CG@6AuGy9HN5DyUJo(>sQl`eQMfwoTi^~)ps~(R~KxCF7yEAh`Qi5K*1~tkS9|n z&va|(dXb4SQ>H04Ms%*9HIJI327T&-5ICQs%$QX#kFq*-kD7Q>w>F<3?Sc)! z&_c%CYL1XsIQ@%u5+vJ*aUy{B8%Yujk|u!1e*=LR*(Q=?f~705>HQQ_3rSStw^_@W zQ?FGwRXtDO}HAo88bu0B93xATY-&v?>T(y(> z&36e`@44K6pCz6xYt_URnBSXscI3<*_iLaAy7>K?hqx-{4qZ942BH(X_XHN%+^&(^ zp3}M4&gN`qf4|CN{R2wb-)QOCOx|zlsp38+HpqTl>T9BktFel0SStp;vi%mbBaragE$DVq6;G=E>%7}PN%}dD8kPW1!l&7hd?y{ zRbVcR|7H~}6?qK6(tzYmXZIt>;Oxq&Ryu(vK<8`R0H&Pi7zD$kRAeNA+V_Z)b-@J! zWR!7{=h!IH$GV^&>2@5w%-6v4OhXD244D<{`D9842CZ0ue_zUo355gN0<)Y0ftt($ zho{ILg%KX&?iI0fQ%MlWsJ(#J0bhu(0u03}DhQxLhjxD0@?pzj%jJo=%$3ZtXVWdu zrjHW9?5&GOu2(AgmD94WW| zVH50r+5Ivo%C|8^fa+k{43mH!a2D%=(Zc}tfXWrmm4}R$VYr}}l~t=s zXr*3POa&GHW!vCOhxR21r}e@3vP>I%>D0cgUH=_1#AO1k3E(ECXc-vrS+Y~6F7#N! z472PQTE8@mvZbpVGM#``Q6Hu$%fh%WIxeI61)&bSf^Nl*hx!!d0=6tTgocwdU}IyB zx`q_Xcv2is36O%Q6GX+90^>oRLPI>2Nl&O*ix^KNr@+~#`-u)G<4{Vb$5Q~AKrK|U zf)$b;%3M!=(&PAk+B15MKK|eqSsF=@X z64P3jDm?PlqEN2X_@P8ymoW-`5g;fVi2xS>%g$DHyJqvkrF>1eV5_{^cYW~M;3~&9 zT4s-erfc&qTmA4qXZ05v+m{==mKwV*n=ik%Wb=P!^(?Lx1b7Z@o497INkAweV#tWx#U>E*~hgZ~frF`v-1T{H*=? z%SW#^FS&xyw(JZnIRm$xorUI2%gtR&&0WyuzjBf`H7z-tZaG7RVCQo1xuxK9&=gp5 zHPN<~WoJvy+49-GmzMVp-r6^Kmo>F}e!g$;CU@J{v2c3Xw=L(}wzO~X4%WkmO-rs8 zScA(qd*XNhYlk|?0x13I-r7j5`I)r_g%#tM#9ru7wI$uEb6eN#pQJzhEqo}UX9-P7 zD-*thQLcW+DyBDg(Bh*40`+UEKzx&aIcgm538)3e4E`-(o`%o>AT98WEg0{Re&7J* z=4m(j0~pWPXqE}wArR%7o&yt=3EZ5a#o{ahm!tiLNpY8vy8R z(TTerpZoI8rkuH{IMXi@p}tg7qK@J-Pm9%m#l@g~gKUXqwjvt0=4!VgR=4JMzC_E# z$pRn^UldxEs!M|KdLll3RFKBOexO&1<BBwv3FKnA9_p{o|6`2(w%EP|^RlH%+DmWB$5DcAij#Iq%C zB(Rssb6{T;g&^9`MR#lw!(%A!XxL>|Q)*q5vx-c6!3~h;LqR)cSVB=;-MYZDptqOl z0cy)wGbK(mTn`P@UMt%NtjzKp%UgBV*99hkRzj8jWii#g0$Fy1jUp>s7kFnE(O)lQ zxh`<8jc`8F`xCGztRt2vZrHjohU}Y$5o_5VUA$ZGajfYHupV7~(bEn3^y=+nEl=pt zr$g%L20d2j?PIM^=&@3V)YA=mtkT=Z+Mm#4wGOGLd`-7TDSAx{Iq_4~Y8%^R^k&&$ z4{P5K7HG7&sJd~)ca?jO`2)0(ue^`X0l6pQ*2K`|v03Q?RT zuOk%S`sWc~6l?D@;MP~QY#l|*R?NU6l&UhXA>iJ}Mu&;PAZEPEgWDDL;fMG)@R5jW zwPqx%hAZt$%wo0sp(h-07Y=S{X(}a4iWgfz4CwZAs7gf6Lp{hM+NnxqY&g=xAL)lpnbL<*w7_YP($$L-F_J3vtdpodX4a`fo zt!*nd&z$RutKiyQa8)n6f@tTus^rSOql*U|qx zoU7_9y{)oxuQG+o2J~!xAFSwAAMw=8y>{ib`TB+D^6t&Ey#^=*;|@zI>(?5ItD)elE7Z5V=X}Sxa57)N3)Y6W zZ7gT`?OK?)YS#z{D>QI*SVs{DcXewvKoMRH4Bhse%$*_toetM9D{4<;J;sW$uyb>1iR_uw&#s2`1pNPnpj4Fv@EzA@rs{@4TO?𔴣^if{SeGuq z^mGF_x87c&gp^ln9#t&L!xa^aqN-9J@=?X2Je;9eY_JAHo!Fqgs74f3o$@0+qDGXc zGUdOh(ebCJd~LTzYgAz{+}|Y%!{e#a4hlm#`YS37rS#`0(il}3OzRYex*40MFoYGh zFETPDUIP*nzlRa30OI#C`U{NqWAv98{S`*@7`=?ta^FH?V_Ye2~4@Uo77T+=|R;@?49i~Pu3h7WqQSyTO=4fQ3 z=h(pTdZ{ZeVqZ5f+KCbR4e8q*@t-h9nH-AS^%C|n*1Zi8yqj6Td>IKVVP{mJs?xLq zoP6rbznJ<~L=ry&2+AWbmPpiv{>4`oj~hajo0k@j-rjm3?>+cO5vOZD0@AXg{(9hA zV18s_Z@yytiaWSa_d($Oz~Y&F>x+5!eh^^So!6YX#;&|Cyi(D!VEw@PzH{+-KG>bF z*jw<{&b@i%O}OFqcC1u`C;o#s-+%Mw{(R?4`Rbkm$m-XxyguKV_q1vGTm!yN(fXVp z$ooPoo`(7E_g;MG#a!q9eBeOdb8w|Xn?i2K;e6|ne8thVIu}Y!7fMaF_ya{~ahWvzHv;Y^Srs9hfo_)D;%-~Fhlx0WRa@Ui* zxkSnu+$w5tCVdQ$vRwHdpw?G}(sLzJ)-b*CPZrAe0aDfmQr7;Elx3ra9MyX*k+S9y zb2$^7cb6FL7BJeuR0pGd7L0Zq7^jATUhJ{N5Vw`>(ZhQUqrE71%ELY?3CqJ7lCVTB zm4`HBqAr(4%EKA5k+1GXh6X|Z0u1JFXa;k;+Cs>P6%1>jUkp^f)v9?y4Ee^5J)RyM zAf?Ogd{MrY?!2?OyCWPJ#I7uNh$}IOYsER3fjEZI8H}!C^prAgNVSrvqyYw_<+V!DkjiT?pmls}y-kyyG7Sa8+N z*Dd?Imi%2e9Ut}mc<_gVIp^`&qq=Rl?O@(}=#L`Ap2{X{d^Ve~#OzyhTF}U|=|)~H zakVdY-*!HqGe7^^wa_z4r=Pfe5te+yI(r%R6HixUKl{mk4)UQ8K1)771#jKqS+WRR zAD-5s7Xkj%L_CNk3*5?M0hzmag0`QJ!yDvml4?3iHEyi&hdR>u1pyxZ(u+t{W>IlP zl_2V7pj3v3mpT{3LzqMNhGGZRW`MdcRE0{yN)G<8WKt5pgHU))CjL7{Kf(wZFnzM8 zI!0c_B6>$uAI8@)_clhCG5Rh>KfnmpB}#$da$(^emBN8{3H~AqIm6DKs+MKjufEeh zDE=4JA}5mE5X0j+hWRCF`!(_Wg4lmaYJW{!za*{q&349gpFngMqt#l{(Dfhx{|ZwSm>&Kjg~6OEf`+z$8u_3&x+ zs)y`3&MfafxwQM_?cJwt*YQicPrX00AkA~{Iv{(ijz=hqjjLW|2~)M&;NU7SSH0S7 z=BjA!ex-wf2lWu$Yj84_dyVi+^KwroZIK#jyM<){vGhGi!ZeK?aBi7Clr zcUMBpqJW$haE!EwQ52{j{E&yDu+aj2?Vmwc1}v;&AV7@-LEk#FM~al(s6d;Q zxgWDLyK}#t`R)4q;ZPreGWCyZl?@Lef5$Yc^~)5zI)c6@8f;4@0ktcgFKiI@lZa@!&2s*J&=#^NIuG= z`52Go5AuWge%_xXMQxPmKHXnb3a@wE>b5Y~rw2}ecUW>tR?&l}+9N(#B!#Rq(_4d0 z=!D`(c%nF1INm$!^!0E@Lq8BFdL&M&krQn-J_I(WUbQLuc5I?x6We7o3^u1&(h%BD!dCqfs?W zmQl92S*bD+r53ZRE0$5UOVXB!5VK5LT5=Rw#w`Zt+HbCb2@mJ)|P9j*qJ>G6_ z5xOGR>sW<5Lr1yk`N^#mexn7glgri2k1I8H ztY(&O+V)S6nH4%SJvliENV$21S1sXX7G1F@YsT?os_hTRuI%8_bmtnC`eF&AxE;yF z1${h)Ej*}IGEZ)()4zgq1lcfoPgzoQWdUZKve3>ELb|=<>Kj9ydCa9Tl2Koxp72nM zFVi|W=qguATxg8rsdzrLY0cyeJxGglxvStd%Q0?M8Wy*51!{j}l{m}wengJZ(r!V` ztkkoOTOw!}X1#86lUs&C55t=14a3eV`GzP;aucgxmw$Ksfp%~DH+4DOf@xC1s#DNo zi38HwD$yYtoo^A|n#Gv8XbBg@(c&XQQ=ma1&I!e>Kn$4DQRp6m9}7XXO1=sX+*@e{ zMp~{Bsk#eu;7ez(Gg5bd%yqSkwXQ9=z(kgu21vW;01SkeIvFA~>*Ep~1W`t#s7Y}v z>P3M9_<-R!Nv=N1#H)LW*Y-t1m!e3MuN36<(<6c|ND=)#r9;3c+|ERj zEF9>lKyXKQ`s)7nIc0~awGQ=;ktwaBWL(WRy6ls(kbY8jJ$019zijHJUA}b|`clrI zSNS8Hg77#T1nCiA5WdUIKt>bZl3nLkooD>QQ?(ZtEh^m7Qg~_>U$VMh-Mb(8Pf1B^=|n zkGKPO=5Eie`%bR=4tyTI8^$JZH}KS#cKJ*X$&7K}k%^+xFswr{B=*Y@+>BwqJFJpM z8c9DmyMfK0Z^%(M(LqfXbW^&TfDU8@CZ8V z7;m}8I~d~4gNea%?bpF~havqT>Vc?Z)7|ns+i#v z-{lVBfybp)UlAI7tnkBNPLemx;944t7uLX+@ef?1LYm_qSRhTX9}VZXR@H`J)A zR%#?xNhA3oQvFE!(2|#`4}FN#KIBhuv{r3Ksj8|{E8g6C)vCOt=iHfHk6{T&=}2?t z-gD2~nR|Zc{O*}~Ar=i0IF9`NviYYlA%DY8`%s*Quqg_J)Jcx0!dWsUFkuMW;#qM@ zV$zh%Vp>z+P-O>O_%3C z7j1Fgu*ITbD|5w)&TOg31q_kwgI|C1%{LFGLBJgWO)KjqL(}Y#rj;s%S`ph3O}kms zi|$BV)87F|~iRvN^z&u>|pka-H zxI^wI`>@aHH;Nr75D&*8&tYqzEr1Y+^%97udbBC; zyWc;seDl*A^{IxjCJ#H);Y{27ix|NnY|5NcI(KK7l_;u$KAQDI zUI;moQ&iv4Hn{{;Ka2;s4+ZdH5PBhxwV;MOSrh{LP}C?N4I@zEj+E*5xMca_aHUYLkC0%S|+&18`O@S>COc&d@+RGBE0GRjL{?lfWK@wyiCW@xT zerz#1oBnA!TPZ`j5)nlWE5GPB3=(8Jki_6 zp^{nFD1=mb-kshov(EI9DmC+lRx~~^idIIXdtm~b-%>Qn^K9W^2wtW=mH&pZc&kDS zd32oXJR(CI;1D*`(5RCck`s@8NJs&6gXA4yK~M!%R3%lM6k)OQ10*$D0b;Bf6mPM8 z)L=DQ<}nDt?VGF6lFlrge}rN z1iUjNW^bRlP%bW}0l+lkmo}}mRjXDjlo^GIG%#NJCMs<2~W3hG2`Qon_}*6V`w~P?Ebc+w(F7itQSGl z$%c$9>-cdpZG6 zYX3p@QDv^|(pBprGP1V&^BSsx+`VrBoU8~l3W0`EVLEN%)M*#1dWf*%I9+i>l zF}QFa+6vEF+i#XRz?KXndFGc#mX3U8H2f>6slS9%5Hl;g&o@)ql}L6y-rI=$_g{X}b5^hr zh>uez1g-DHLTW8~xDZB@ZttLpdSWs|GtIQ4qeJd}CC3=K|di*0uy&y{+(#DSNxeTZgs_fGF1FxJPN%p*p=Cb0e-j?4NV$7&^wM z15N{uGD{|-FQ6h+)#qVvs@yQ#%|6mKqlMfJ4~)5Tpmq6HXC33q>sVzXYFdGW2)>t4h3_mEDqG0$lwt<`l0I;2MM; zFJ8eisD_~zac2psQRv0oURaGoFTs0B7EvX@G-XHL#xJFJYo(G-7wKE@Vs?OkJZWYj z>i{6*SDfvKLW3HX^)TvuBDT>Bw85n~Gn8dc%@_h0+a_VpUcrku%JY8&?CB_qIEYNN zE3Evp<02YeLU9?zk5F7i!RO&qbi)>jn4&lVrsw*=0n7-A-;TA@ z%&NkF0^@dySNC3=W^F$}0p+{pLKH6>KVjOViQ<2qvXVD>a z#>ja6!qU|>dE8ky8f{y*4-{|+n+TU@-4~ulR-BL&Af%8gcL>Q#Uw#O4{LT&9{cV0x z4XaZ8z;L;UV)5Zb?-toG@CoM>iqJbefRUEX1iLzhGl-6+Jm@%YbPKEZ&;$iqw0|5t zx98sQ>T?I`=a+yo4j?<+y!+0*msXP#$PR1rgyS^4ZJX01P{1K9Q{<4M~6FG05?<@a=RA1dD$71kvR91UtxGH*||LW(fDw21bjt%M+qEyF}`X$8V~@4pFL_>2AzTlh3w literal 0 HcmV?d00001 diff --git a/skills/local-places/src/local_places/google_places.py b/skills/local-places/src/local_places/google_places.py new file mode 100644 index 000000000..5a9bd60a3 --- /dev/null +++ b/skills/local-places/src/local_places/google_places.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import logging +import os +from typing import Any + +import httpx +from fastapi import HTTPException + +from local_places.schemas import ( + LatLng, + LocationResolveRequest, + LocationResolveResponse, + PlaceDetails, + PlaceSummary, + ResolvedLocation, + SearchRequest, + SearchResponse, +) + +GOOGLE_PLACES_BASE_URL = os.getenv( + "GOOGLE_PLACES_BASE_URL", "https://places.googleapis.com/v1" +) +logger = logging.getLogger("local_places.google_places") + +_PRICE_LEVEL_TO_ENUM = { + 0: "PRICE_LEVEL_FREE", + 1: "PRICE_LEVEL_INEXPENSIVE", + 2: "PRICE_LEVEL_MODERATE", + 3: "PRICE_LEVEL_EXPENSIVE", + 4: "PRICE_LEVEL_VERY_EXPENSIVE", +} +_ENUM_TO_PRICE_LEVEL = {value: key for key, value in _PRICE_LEVEL_TO_ENUM.items()} + +_SEARCH_FIELD_MASK = ( + "places.id," + "places.displayName," + "places.formattedAddress," + "places.location," + "places.rating," + "places.priceLevel," + "places.types," + "places.currentOpeningHours," + "nextPageToken" +) + +_DETAILS_FIELD_MASK = ( + "id," + "displayName," + "formattedAddress," + "location," + "rating," + "priceLevel," + "types," + "regularOpeningHours," + "currentOpeningHours," + "nationalPhoneNumber," + "websiteUri" +) + +_RESOLVE_FIELD_MASK = ( + "places.id," + "places.displayName," + "places.formattedAddress," + "places.location," + "places.types" +) + + +class _GoogleResponse: + def __init__(self, response: httpx.Response): + self.status_code = response.status_code + self._response = response + + def json(self) -> dict[str, Any]: + return self._response.json() + + @property + def text(self) -> str: + return self._response.text + + +def _api_headers(field_mask: str) -> dict[str, str]: + api_key = os.getenv("GOOGLE_PLACES_API_KEY") + if not api_key: + raise HTTPException( + status_code=500, + detail="GOOGLE_PLACES_API_KEY is not set.", + ) + return { + "Content-Type": "application/json", + "X-Goog-Api-Key": api_key, + "X-Goog-FieldMask": field_mask, + } + + +def _request( + method: str, url: str, payload: dict[str, Any] | None, field_mask: str +) -> _GoogleResponse: + try: + with httpx.Client(timeout=10.0) as client: + response = client.request( + method=method, + url=url, + headers=_api_headers(field_mask), + json=payload, + ) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail="Google Places API unavailable.") from exc + + return _GoogleResponse(response) + + +def _build_text_query(request: SearchRequest) -> str: + keyword = request.filters.keyword if request.filters else None + if keyword: + return f"{request.query} {keyword}".strip() + return request.query + + +def _build_search_body(request: SearchRequest) -> dict[str, Any]: + body: dict[str, Any] = { + "textQuery": _build_text_query(request), + "pageSize": request.limit, + } + + if request.page_token: + body["pageToken"] = request.page_token + + if request.location_bias: + body["locationBias"] = { + "circle": { + "center": { + "latitude": request.location_bias.lat, + "longitude": request.location_bias.lng, + }, + "radius": request.location_bias.radius_m, + } + } + + if request.filters: + filters = request.filters + if filters.types: + body["includedType"] = filters.types[0] + if filters.open_now is not None: + body["openNow"] = filters.open_now + if filters.min_rating is not None: + body["minRating"] = filters.min_rating + if filters.price_levels: + body["priceLevels"] = [ + _PRICE_LEVEL_TO_ENUM[level] for level in filters.price_levels + ] + + return body + + +def _parse_lat_lng(raw: dict[str, Any] | None) -> LatLng | None: + if not raw: + return None + latitude = raw.get("latitude") + longitude = raw.get("longitude") + if latitude is None or longitude is None: + return None + return LatLng(lat=latitude, lng=longitude) + + +def _parse_display_name(raw: dict[str, Any] | None) -> str | None: + if not raw: + return None + return raw.get("text") + + +def _parse_open_now(raw: dict[str, Any] | None) -> bool | None: + if not raw: + return None + return raw.get("openNow") + + +def _parse_hours(raw: dict[str, Any] | None) -> list[str] | None: + if not raw: + return None + return raw.get("weekdayDescriptions") + + +def _parse_price_level(raw: str | None) -> int | None: + if not raw: + return None + return _ENUM_TO_PRICE_LEVEL.get(raw) + + +def search_places(request: SearchRequest) -> SearchResponse: + url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText" + response = _request("POST", url, _build_search_body(request), _SEARCH_FIELD_MASK) + + if response.status_code >= 400: + logger.error( + "Google Places API error %s. response=%s", + response.status_code, + response.text, + ) + raise HTTPException( + status_code=502, + detail=f"Google Places API error ({response.status_code}).", + ) + + try: + payload = response.json() + except ValueError as exc: + logger.error( + "Google Places API returned invalid JSON. response=%s", + response.text, + ) + raise HTTPException(status_code=502, detail="Invalid Google response.") from exc + + places = payload.get("places", []) + results = [] + for place in places: + results.append( + PlaceSummary( + place_id=place.get("id", ""), + name=_parse_display_name(place.get("displayName")), + address=place.get("formattedAddress"), + location=_parse_lat_lng(place.get("location")), + rating=place.get("rating"), + price_level=_parse_price_level(place.get("priceLevel")), + types=place.get("types"), + open_now=_parse_open_now(place.get("currentOpeningHours")), + ) + ) + + return SearchResponse( + results=results, + next_page_token=payload.get("nextPageToken"), + ) + + +def get_place_details(place_id: str) -> PlaceDetails: + url = f"{GOOGLE_PLACES_BASE_URL}/places/{place_id}" + response = _request("GET", url, None, _DETAILS_FIELD_MASK) + + if response.status_code >= 400: + logger.error( + "Google Places API error %s. response=%s", + response.status_code, + response.text, + ) + raise HTTPException( + status_code=502, + detail=f"Google Places API error ({response.status_code}).", + ) + + try: + payload = response.json() + except ValueError as exc: + logger.error( + "Google Places API returned invalid JSON. response=%s", + response.text, + ) + raise HTTPException(status_code=502, detail="Invalid Google response.") from exc + + return PlaceDetails( + place_id=payload.get("id", place_id), + name=_parse_display_name(payload.get("displayName")), + address=payload.get("formattedAddress"), + location=_parse_lat_lng(payload.get("location")), + rating=payload.get("rating"), + price_level=_parse_price_level(payload.get("priceLevel")), + types=payload.get("types"), + phone=payload.get("nationalPhoneNumber"), + website=payload.get("websiteUri"), + hours=_parse_hours(payload.get("regularOpeningHours")), + open_now=_parse_open_now(payload.get("currentOpeningHours")), + ) + + +def resolve_locations(request: LocationResolveRequest) -> LocationResolveResponse: + url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText" + body = {"textQuery": request.location_text, "pageSize": request.limit} + response = _request("POST", url, body, _RESOLVE_FIELD_MASK) + + if response.status_code >= 400: + logger.error( + "Google Places API error %s. response=%s", + response.status_code, + response.text, + ) + raise HTTPException( + status_code=502, + detail=f"Google Places API error ({response.status_code}).", + ) + + try: + payload = response.json() + except ValueError as exc: + logger.error( + "Google Places API returned invalid JSON. response=%s", + response.text, + ) + raise HTTPException(status_code=502, detail="Invalid Google response.") from exc + + places = payload.get("places", []) + results = [] + for place in places: + results.append( + ResolvedLocation( + place_id=place.get("id", ""), + name=_parse_display_name(place.get("displayName")), + address=place.get("formattedAddress"), + location=_parse_lat_lng(place.get("location")), + types=place.get("types"), + ) + ) + + return LocationResolveResponse(results=results) diff --git a/skills/local-places/src/local_places/main.py b/skills/local-places/src/local_places/main.py new file mode 100644 index 000000000..1197719de --- /dev/null +++ b/skills/local-places/src/local_places/main.py @@ -0,0 +1,65 @@ +import logging +import os + +from fastapi import FastAPI, Request +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from local_places.google_places import get_place_details, resolve_locations, search_places +from local_places.schemas import ( + LocationResolveRequest, + LocationResolveResponse, + PlaceDetails, + SearchRequest, + SearchResponse, +) + +app = FastAPI( + title="My API", + servers=[{"url": os.getenv("OPENAPI_SERVER_URL", "http://maxims-macbook-air:8000")}], +) +logger = logging.getLogger("local_places.validation") + + +@app.get("/ping") +def ping() -> dict[str, str]: + return {"message": "pong"} + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: + logger.error( + "Validation error on %s %s. body=%s errors=%s", + request.method, + request.url.path, + exc.body, + exc.errors(), + ) + return JSONResponse( + status_code=422, + content=jsonable_encoder({"detail": exc.errors()}), + ) + + +@app.post("/places/search", response_model=SearchResponse) +def places_search(request: SearchRequest) -> SearchResponse: + return search_places(request) + + +@app.get("/places/{place_id}", response_model=PlaceDetails) +def places_details(place_id: str) -> PlaceDetails: + return get_place_details(place_id) + + +@app.post("/locations/resolve", response_model=LocationResolveResponse) +def locations_resolve(request: LocationResolveRequest) -> LocationResolveResponse: + return resolve_locations(request) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("local_places.main:app", host="0.0.0.0", port=8000) diff --git a/skills/local-places/src/local_places/schemas.py b/skills/local-places/src/local_places/schemas.py new file mode 100644 index 000000000..e0590e659 --- /dev/null +++ b/skills/local-places/src/local_places/schemas.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field, field_validator + + +class LatLng(BaseModel): + lat: float = Field(ge=-90, le=90) + lng: float = Field(ge=-180, le=180) + + +class LocationBias(BaseModel): + lat: float = Field(ge=-90, le=90) + lng: float = Field(ge=-180, le=180) + radius_m: float = Field(gt=0) + + +class Filters(BaseModel): + types: list[str] | None = None + open_now: bool | None = None + min_rating: float | None = Field(default=None, ge=0, le=5) + price_levels: list[int] | None = None + keyword: str | None = Field(default=None, min_length=1) + + @field_validator("types") + @classmethod + def validate_types(cls, value: list[str] | None) -> list[str] | None: + if value is None: + return value + if len(value) > 1: + raise ValueError( + "Only one type is supported. Use query/keyword for additional filtering." + ) + return value + + @field_validator("price_levels") + @classmethod + def validate_price_levels(cls, value: list[int] | None) -> list[int] | None: + if value is None: + return value + invalid = [level for level in value if level not in range(0, 5)] + if invalid: + raise ValueError("price_levels must be integers between 0 and 4.") + return value + + @field_validator("min_rating") + @classmethod + def validate_min_rating(cls, value: float | None) -> float | None: + if value is None: + return value + if (value * 2) % 1 != 0: + raise ValueError("min_rating must be in 0.5 increments.") + return value + + +class SearchRequest(BaseModel): + query: str = Field(min_length=1) + location_bias: LocationBias | None = None + filters: Filters | None = None + limit: int = Field(default=10, ge=1, le=20) + page_token: str | None = None + + +class PlaceSummary(BaseModel): + place_id: str + name: str | None = None + address: str | None = None + location: LatLng | None = None + rating: float | None = None + price_level: int | None = None + types: list[str] | None = None + open_now: bool | None = None + + +class SearchResponse(BaseModel): + results: list[PlaceSummary] + next_page_token: str | None = None + + +class LocationResolveRequest(BaseModel): + location_text: str = Field(min_length=1) + limit: int = Field(default=5, ge=1, le=10) + + +class ResolvedLocation(BaseModel): + place_id: str + name: str | None = None + address: str | None = None + location: LatLng | None = None + types: list[str] | None = None + + +class LocationResolveResponse(BaseModel): + results: list[ResolvedLocation] + + +class PlaceDetails(BaseModel): + place_id: str + name: str | None = None + address: str | None = None + location: LatLng | None = None + rating: float | None = None + price_level: int | None = None + types: list[str] | None = None + phone: str | None = None + website: str | None = None + hours: list[str] | None = None + open_now: bool | None = None From f2eb2004aa422f792725950e1cef0993fdf12f67 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 16:51:48 +0100 Subject: [PATCH 29/44] docs: thank @jeffersonwarrior for gateway auth --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24940c720..094cf2b17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ - Control UI: refine Web Chat session selector styling (chevron spacing + background). - WebChat: stream live updates for sessions even when runs start outside the chat UI. - Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag. +- Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior). - Auto-reply: strip stray leading/trailing `HEARTBEAT_OK` from normal replies; drop short (≤ 30 chars) heartbeat acks. - Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines. - Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs. From 8d925226cba489060ad70829d2fa7bc0c9e761b3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 16:04:02 +0000 Subject: [PATCH 30/44] docs: expand FAQ with Docker, OAuth, bun vs Node, debugging - Docker/container setup (volumes, pnpm persistence, startup script) - OAuth vs API key billing differences - OAuth callback workarounds for headless/containers - Terminal onboarding vs macOS app (terminal more stable) - bun binary vs Node runtime (Node more stable for WhatsApp) - gateway:watch for debugging - Tailscale link and setup clarification Based on community questions from Discord #help --- docs/faq.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 9 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index 815c27198..50134fbee 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -66,6 +66,35 @@ pnpm clawdis doctor It checks your config, skills status, and gateway health. It can also restart the gateway daemon if needed. +### Terminal onboarding vs macOS app? + +**Use terminal onboarding** (`pnpm clawdis onboard`) — it's more stable right now. + +The macOS app onboarding is still being polished and can have quirks (e.g., WhatsApp 515 errors, OAuth issues). + +--- + +## Authentication + +### OAuth vs API key — what's the difference? + +- **OAuth** — Uses your Claude Pro/Max subscription ($20-100/mo flat). No per-token charges. âś… Recommended! +- **API key** — Pay-per-token via console.anthropic.com. Can get expensive fast. + +They're **separate billing**! An API key does NOT use your subscription. + +**For OAuth:** During onboarding, pick "Anthropic OAuth", log in to your Claude account, paste the code back. + +**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdis/` to your server. The auth is just a JSON file. + +### OAuth callback not working (containers/headless)? + +OAuth needs the callback to reach the machine running the CLI. Options: + +1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdis/credentials/` to the container. +2. **SSH tunnel** — `ssh -L 18789:localhost:18789 user@server` +3. **Tailscale** — Put both machines on your tailnet. + --- ## Migration & Deployment @@ -108,16 +137,30 @@ There's no official Docker setup yet, but it works. Key considerations: - **WhatsApp login:** QR code works in terminal — no display needed. - **Persistence:** Mount `~/.clawdis/` and your workspace as volumes. +- **pnpm doesn't persist:** Global npm installs don't survive container restarts. Install pnpm in your startup script. - **Browser automation:** Optional. If needed, install headless Chrome + Playwright deps, or connect to a remote browser via `--remote-debugging-port`. -Basic approach: -```dockerfile -FROM node:22 -WORKDIR /app -# Clone, pnpm install, pnpm build -# Mount volumes for persistence -CMD ["pnpm", "clawdis", "gateway"] +**Volume mappings (e.g., Unraid):** ``` +/mnt/user/appdata/clawdis/config → /root/.clawdis +/mnt/user/appdata/clawdis/workspace → /root/clawd +/mnt/user/appdata/clawdis/app → /app +``` + +**Startup script (`start.sh`):** +```bash +#!/bin/bash +npm install -g pnpm +cd /app +pnpm clawdis gateway +``` + +**Container command:** +``` +bash /app/start.sh +``` + +Docker support is on the roadmap — PRs welcome! ### Can I run Clawdis headless on a VPS? @@ -126,6 +169,18 @@ Yes! The terminal QR code login works fine over SSH. For long-running operation: - Use `pm2`, `systemd`, or a `launchd` plist to keep the gateway running. - Consider Tailscale for secure remote access. +### bun binary vs Node runtime? + +Clawdis can run as: +- **bun binary** — Single executable, easy distribution, auto-restarts via launchd +- **Node runtime** (`pnpm clawdis gateway`) — More stable for WhatsApp + +If you see WebSocket errors like `ws.WebSocket 'upgrade' event is not implemented`, use Node instead of the bun binary. Bun's WebSocket implementation has edge cases that can break WhatsApp (Baileys). + +**For stability:** Use launchd (macOS) or the Clawdis.app — they handle process supervision (auto-restart on crash). + +**For debugging:** Use `pnpm gateway:watch` for live reload during development. + --- ## Multi-Instance & Contexts @@ -226,12 +281,14 @@ No gateway restart needed! ### How do I run commands on other machines? -Use **Tailscale** to create a secure network between your machines: +Use **[Tailscale](https://tailscale.com/)** to create a secure network between your machines: -1. Install Tailscale on all machines +1. Install Tailscale on all machines (it's separate from Clawdis — set it up yourself) 2. Each gets a stable IP (like `100.x.x.x`) 3. SSH just works: `ssh user@100.x.x.x "command"` +Clawdis can use Tailscale when you set `bridge.bind: "tailnet"` in your config — it auto-detects your Tailscale IP. + For deeper integration, look into **Clawdis nodes** — pair remote machines with your gateway for camera/screen/automation access. --- @@ -269,6 +326,11 @@ Common issues: - Missing API keys in config - Invalid config syntax (remember it's JSON5, but still check for errors) +**Debug mode** — use watch for live reload: +```bash +pnpm gateway:watch +``` + **Pro tip:** Use Codex to debug: ```bash cd ~/path/to/clawdis From 314164fb8a31fed1c2fed9d8b14f531b8fc53eac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 16:56:27 +0100 Subject: [PATCH 31/44] chore: fix lint and add gateway auth tests --- .../Clawdis/GatewayEndpointStore.swift | 77 ++++++++++++------- .../Clawdis/GatewayLaunchAgentManager.swift | 4 + .../GatewayEndpointStoreTests.swift | 38 +++++++++ .../LowCoverageHelperTests.swift | 3 + src/auto-reply/reply.ts | 4 +- src/cli/program.ts | 4 +- src/commands/doctor.ts | 10 ++- src/config/config.test.ts | 4 +- src/config/config.ts | 4 +- src/gateway/server.ts | 3 +- 10 files changed, 115 insertions(+), 36 deletions(-) diff --git a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift index 7be78c407..1c2aa841d 100644 --- a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift @@ -26,40 +26,51 @@ actor GatewayEndpointStore { mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] }, password: { - let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PASSWORD"] ?? "" - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - return trimmed - } let root = ClawdisConfigFile.loadDict() - if CommandResolver.connectionModeIsRemote() { - if let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let password = remote["password"] as? String - { - let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) - if !pw.isEmpty { - return pw - } - } - return nil - } - if let gateway = root["gateway"] as? [String: Any], - let auth = gateway["auth"] as? [String: Any], - let password = auth["password"] as? String - { - let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) - if !pw.isEmpty { - return pw - } - } - return nil + return GatewayEndpointStore.resolveGatewayPassword( + isRemote: CommandResolver.connectionModeIsRemote(), + root: root, + env: ProcessInfo.processInfo.environment) }, localPort: { GatewayEnvironment.gatewayPort() }, remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() }) } + private static func resolveGatewayPassword( + isRemote: Bool, + root: [String: Any], + env: [String: String] + ) -> String? { + let raw = env["CLAWDIS_GATEWAY_PASSWORD"] ?? "" + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + if isRemote { + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let password = remote["password"] as? String + { + let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) + if !pw.isEmpty { + return pw + } + } + return nil + } + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let password = auth["password"] as? String + { + let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) + if !pw.isEmpty { + return pw + } + } + return nil + } + private let deps: Deps private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway-endpoint") @@ -193,3 +204,15 @@ actor GatewayEndpointStore { } } } + +#if DEBUG +extension GatewayEndpointStore { + static func _testResolveGatewayPassword( + isRemote: Bool, + root: [String: Any], + env: [String: String] + ) -> String? { + self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env) + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift index 7d0f04995..eeee7f344 100644 --- a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift @@ -226,5 +226,9 @@ extension GatewayLaunchAgentManager { static func _testPreferredGatewayToken() -> String? { self.preferredGatewayToken() } + + static func _testEscapePlistValue(_ raw: String) -> String { + self.escapePlistValue(raw) + } } #endif diff --git a/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift index 6061fc8f9..891fb35cc 100644 --- a/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift @@ -82,6 +82,44 @@ import Testing #expect(password == "pw") } + @Test func resolvesGatewayPasswordByMode() { + let root: [String: Any] = [ + "gateway": [ + "auth": ["password": " local "], + "remote": ["password": " remote "], + ], + ] + let env: [String: String] = [:] + + #expect(GatewayEndpointStore._testResolveGatewayPassword( + isRemote: false, + root: root, + env: env) == "local") + #expect(GatewayEndpointStore._testResolveGatewayPassword( + isRemote: true, + root: root, + env: env) == "remote") + } + + @Test func gatewayPasswordEnvOverridesConfig() { + let root: [String: Any] = [ + "gateway": [ + "auth": ["password": "local"], + "remote": ["password": "remote"], + ], + ] + let env = ["CLAWDIS_GATEWAY_PASSWORD": " env "] + + #expect(GatewayEndpointStore._testResolveGatewayPassword( + isRemote: false, + root: root, + env: env) == "env") + #expect(GatewayEndpointStore._testResolveGatewayPassword( + isRemote: true, + root: root, + env: env) == "env") + } + @Test func unconfiguredModeRejectsConfig() async { let mode = ModeBox(.unconfigured) let store = GatewayEndpointStore(deps: .init( diff --git a/apps/macos/Tests/ClawdisIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/ClawdisIPCTests/LowCoverageHelperTests.swift index 92a7b891c..f9ae977da 100644 --- a/apps/macos/Tests/ClawdisIPCTests/LowCoverageHelperTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/LowCoverageHelperTests.swift @@ -114,6 +114,9 @@ struct LowCoverageHelperTests { setenv(keyToken, " secret ", 1) #expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan") #expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret") + #expect( + GatewayLaunchAgentManager._testEscapePlistValue("a&b\"'") == + "a&b<c>"'") #expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdis") #expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay") diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index b3c380a38..47ec99056 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -854,7 +854,9 @@ export async function getReplyFromConfig( const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); const defaultAllowFrom = - isWhatsAppSurface && (!configuredAllowFrom || configuredAllowFrom.length === 0) && to + isWhatsAppSurface && + (!configuredAllowFrom || configuredAllowFrom.length === 0) && + to ? [to] : undefined; const allowFrom = diff --git a/src/cli/program.ts b/src/cli/program.ts index 926094987..a6961d339 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -10,11 +10,11 @@ import { sessionsCommand } from "../commands/sessions.js"; import { setupCommand } from "../commands/setup.js"; import { statusCommand } from "../commands/status.js"; import { updateCommand } from "../commands/update.js"; +import { readConfigFileSnapshot } from "../config/config.js"; import { danger, setVerbose } from "../globals.js"; import { loginWeb, logoutWeb } from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; import { VERSION } from "../version.js"; -import { readConfigFileSnapshot } from "../config/config.js"; import { registerBrowserCli } from "./browser-cli.js"; import { registerCanvasCli } from "./canvas-cli.js"; import { registerCronCli } from "./cron-cli.js"; @@ -79,7 +79,7 @@ export function buildProgram() { .join("\n"); defaultRuntime.error( danger( - `Legacy config entries detected. Run \"clawdis doctor\" (or ask your agent) to migrate.\n${issues}`, + `Legacy config entries detected. Run "clawdis doctor" (or ask your agent) to migrate.\n${issues}`, ), ); process.exit(1); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 22696e9e8..635bd28a0 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -30,7 +30,11 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { const snapshot = await readConfigFileSnapshot(); let cfg: ClawdisConfig = snapshot.valid ? snapshot.config : {}; - if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) { + if ( + snapshot.exists && + !snapshot.valid && + snapshot.legacyIssues.length === 0 + ) { note("Config invalid; doctor will run with defaults.", "Config"); } @@ -50,7 +54,9 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { ); if (migrate) { // Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. - const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed); + const { config: migrated, changes } = migrateLegacyConfig( + snapshot.parsed, + ); if (changes.length > 0) { note(changes.join("\n"), "Doctor changes"); } diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 4d46782fe..6ababcbe4 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -508,7 +508,9 @@ describe("legacy config detection", () => { const res = migrateLegacyConfig({ routing: { allowFrom: ["+15555550123"] }, }); - expect(res.changes).toContain("Moved routing.allowFrom → whatsapp.allowFrom."); + expect(res.changes).toContain( + "Moved routing.allowFrom → whatsapp.allowFrom.", + ); expect(res.config?.whatsapp?.allowFrom).toEqual(["+15555550123"]); expect(res.config?.routing?.allowFrom).toBeUndefined(); }); diff --git a/src/config/config.ts b/src/config/config.ts index cd979570b..d58142fcd 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1217,7 +1217,9 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ whatsapp.allowFrom = allowFrom; changes.push("Moved routing.allowFrom → whatsapp.allowFrom."); } else { - changes.push("Removed routing.allowFrom (whatsapp.allowFrom already set)."); + changes.push( + "Removed routing.allowFrom (whatsapp.allowFrom already set).", + ); } delete (routing as Record).allowFrom; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index e3b89bf2e..b27b29f25 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -660,7 +660,6 @@ type DedupeEntry = { error?: ErrorShape; }; - function formatForLog(value: unknown): string { try { if (value instanceof Error) { @@ -1334,7 +1333,7 @@ export async function startGatewayServer( ); if (!migrated) { throw new Error( - "Legacy config entries detected but auto-migration failed. Run \"clawdis doctor\" to migrate.", + 'Legacy config entries detected but auto-migration failed. Run "clawdis doctor" to migrate.', ); } await writeConfigFile(migrated); From 0de6e38ce9b22088b3ffc4d3d2226d4d85d1d73f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 17:14:57 +0100 Subject: [PATCH 32/44] fix(macos): keep config writes on main actor --- apps/macos/Sources/Clawdis/ConfigSettings.swift | 3 ++- apps/macos/Sources/Clawdis/MenuContentView.swift | 3 ++- apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift | 3 ++- apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/macos/Sources/Clawdis/ConfigSettings.swift b/apps/macos/Sources/Clawdis/ConfigSettings.swift index 9dc2c4d01..bab1250e5 100644 --- a/apps/macos/Sources/Clawdis/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdis/ConfigSettings.swift @@ -462,7 +462,8 @@ struct ConfigSettings: View { } } - private nonisolated static func buildAndSaveConfig( + @MainActor + private static func buildAndSaveConfig( configModel: String, customModel: String, heartbeatMinutes: Int?, diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 7a9a78f5c..f8e8d5932 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -171,7 +171,8 @@ struct MenuContent: View { } } - private nonisolated static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool,()) { + @MainActor + private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool,()) { var root = await ConfigStore.load() var browser = root["browser"] as? [String: Any] ?? [:] browser["enabled"] = enabled diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift b/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift index 4ff9f8fa1..3157df615 100644 --- a/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift +++ b/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift @@ -83,7 +83,8 @@ extension OnboardingView { return success } - private nonisolated static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) { + @MainActor + private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) { var root = await ConfigStore.load() var agent = root["agent"] as? [String: Any] ?? [:] let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" diff --git a/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift b/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift index 2015e221a..1621efdd3 100644 --- a/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift +++ b/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift @@ -301,7 +301,8 @@ struct TailscaleIntegrationSection: View { self.restartGatewayIfNeeded() } - private nonisolated static func buildAndSaveTailscaleConfig( + @MainActor + private static func buildAndSaveTailscaleConfig( tailscaleMode: GatewayTailscaleMode, requireCredentialsForServe: Bool, password: String, From 97e06a8eb42227a62a9e6b463c8edba469e9eee2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 17:15:03 +0100 Subject: [PATCH 33/44] chore(canvas): regenerate a2ui bundle hash --- src/canvas-host/a2ui/.bundle.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index ace1fb6ed..32350f99b 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -969df6da368b3a802bf0f7f34bf2e30102ae51d91daf45f1fb9328877e2fb335 +988ec7bedb11cab74f82faf4475df758e6f07866b69949ffc2cce89cb3d8265b From a4f12babb7d444d59fbd096f845e53b76e5b0046 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 17:15:07 +0100 Subject: [PATCH 34/44] test(macos): cover gateway password whitespace --- .../GatewayEndpointStoreTests.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift index 891fb35cc..19d730872 100644 --- a/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift @@ -120,6 +120,25 @@ import Testing env: env) == "env") } + @Test func gatewayPasswordIgnoresWhitespaceValues() { + let root: [String: Any] = [ + "gateway": [ + "auth": ["password": " "], + "remote": ["password": "\n\t"], + ], + ] + let env = ["CLAWDIS_GATEWAY_PASSWORD": " "] + + #expect(GatewayEndpointStore._testResolveGatewayPassword( + isRemote: false, + root: root, + env: env) == nil) + #expect(GatewayEndpointStore._testResolveGatewayPassword( + isRemote: true, + root: root, + env: env) == nil) + } + @Test func unconfiguredModeRejectsConfig() async { let mode = ModeBox(.unconfigured) let store = GatewayEndpointStore(deps: .init( From 8989bd9fd7529c64fb679b1445b85480a4ca9ef0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 17:15:12 +0100 Subject: [PATCH 35/44] fix(auto-reply): default whatsapp self-only on empty config --- src/auto-reply/reply.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 47ec99056..1b85bb096 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -853,6 +853,11 @@ export async function getReplyFromConfig( : undefined; const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); + const isEmptyConfig = Object.keys(cfg).length === 0; + if (isWhatsAppSurface && isEmptyConfig && from && to && from !== to) { + cleanupTyping(); + return undefined; + } const defaultAllowFrom = isWhatsAppSurface && (!configuredAllowFrom || configuredAllowFrom.length === 0) && From 868b438e67cddf1de43d0c32a7e838eca6ed91c2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 17:15:16 +0100 Subject: [PATCH 36/44] test(gateway): fix nix mode mock toggle --- src/gateway/server.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 2bdb753c2..86f21aaa1 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -134,7 +134,7 @@ let testLegacyIssues: Array<{ path: string; message: string }> = []; let testLegacyParsed: Record = {}; let testMigrationConfig: Record | null = null; let testMigrationChanges: string[] = []; -let testIsNixMode = false; +const testIsNixMode = vi.hoisted(() => ({ value: false })); const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 })); vi.mock("../config/sessions.js", async () => { const actual = await vi.importActual( @@ -223,7 +223,9 @@ vi.mock("../config/config.js", () => { return { CONFIG_PATH_CLAWDIS: resolveConfigPath(), STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()), - isNixMode: testIsNixMode, + get isNixMode() { + return testIsNixMode.value; + }, migrateLegacyConfig: (raw: unknown) => ({ config: testMigrationConfig ?? (raw as Record), changes: testMigrationChanges, @@ -310,7 +312,7 @@ beforeEach(async () => { testLegacyParsed = {}; testMigrationConfig = null; testMigrationChanges = []; - testIsNixMode = false; + testIsNixMode.value = false; cronIsolatedRun.mockClear(); drainSystemEvents(); resetAgentRunContextForTest(); @@ -574,7 +576,7 @@ describe("gateway server", () => { }, ]; testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } }; - testIsNixMode = true; + testIsNixMode.value = true; const port = await getFreePort(); await expect(startGatewayServer(port)).rejects.toThrow( From baf3bea574950db7890071f54555309d268bc77f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 17:16:49 +0100 Subject: [PATCH 37/44] docs(changelog): note macOS config actor fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 094cf2b17..a73b9ad42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ - Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag. - Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior). - Auto-reply: strip stray leading/trailing `HEARTBEAT_OK` from normal replies; drop short (≤ 30 chars) heartbeat acks. +- WhatsApp auto-reply: default to self-only when no config is present. - Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines. - Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs. - Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured. @@ -70,6 +71,7 @@ - CLI onboarding: always prompt for WhatsApp `whatsapp.allowFrom` and print (optionally open) the Control UI URL when done. - CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode). - macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states. +- macOS: keep config writes on the main actor to satisfy Swift concurrency rules. - macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient `cancelled` device refresh errors, and auto-recover the control channel on disconnect. - macOS: log health refresh failures and recovery to make gateway issues easier to diagnose. - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b From 8b27c034725919c785c45a108ef6c6cb57bb0223 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 16:22:18 +0000 Subject: [PATCH 38/44] docs(skills/local-places): add emoji and tagline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 📍 Find places, Go fast 🦞 --- skills/local-places/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/local-places/SKILL.md b/skills/local-places/SKILL.md index bc563d419..1a6acb07b 100644 --- a/skills/local-places/SKILL.md +++ b/skills/local-places/SKILL.md @@ -5,7 +5,9 @@ homepage: https://github.com/Hyaxia/local_places metadata: {"clawdis":{"emoji":"📍","requires":{"bins":["uv"],"env":["GOOGLE_PLACES_API_KEY"]},"primaryEnv":"GOOGLE_PLACES_API_KEY"}} --- -# Local Places +# 📍 Local Places + +*Find places, Go fast* Search for nearby places using a local Google Places API proxy. Two-step flow: resolve location first, then search. From 2a6248dad6350fec39a4017f9e2eae6ad11e1b40 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 17:31:59 +0100 Subject: [PATCH 39/44] fix: add camera entitlement to macOS signing --- CHANGELOG.md | 1 + scripts/codesign-mac-app.sh | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a73b9ad42..228d580b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ - macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient `cancelled` device refresh errors, and auto-recover the control channel on disconnect. - macOS: log health refresh failures and recovery to make gateway issues easier to diagnose. - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b +- macOS codesign: include camera entitlement so permission prompts work in the menu bar app. - macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b - macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b - macOS remote: route settings through gateway config and avoid local config reads in remote mode. diff --git a/scripts/codesign-mac-app.sh b/scripts/codesign-mac-app.sh index 3d558fb65..8e9bb6631 100755 --- a/scripts/codesign-mac-app.sh +++ b/scripts/codesign-mac-app.sh @@ -98,6 +98,8 @@ cat > "$ENT_TMP_BASE" <<'PLIST' com.apple.security.device.audio-input + com.apple.security.device.camera + PLIST @@ -111,6 +113,8 @@ cat > "$ENT_TMP_APP_BASE" <<'PLIST' com.apple.security.device.audio-input + com.apple.security.device.camera + PLIST @@ -139,6 +143,8 @@ cat > "$ENT_TMP_APP" <<'PLIST' com.apple.security.device.audio-input + com.apple.security.device.camera + PLIST From 2d164508696e426d3bde602b226f792cdf74300c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 16:33:12 +0000 Subject: [PATCH 40/44] feat: add weather skill (wttr.in + Open-Meteo fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No API key required. Two services: - wttr.in: human-readable, emoji, ASCII art, PNG - Open-Meteo: JSON API fallback for programmatic use 🌤️🦞 --- skills/weather/SKILL.md | 49 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 skills/weather/SKILL.md diff --git a/skills/weather/SKILL.md b/skills/weather/SKILL.md new file mode 100644 index 000000000..7ee3d87eb --- /dev/null +++ b/skills/weather/SKILL.md @@ -0,0 +1,49 @@ +--- +name: weather +description: Get current weather and forecasts (no API key required). +homepage: https://wttr.in/:help +metadata: {"clawdis":{"emoji":"🌤️","requires":{"bins":["curl"]}}} +--- + +# Weather + +Two free services, no API keys needed. + +## wttr.in (primary) + +Quick one-liner: +```bash +curl -s "wttr.in/London?format=3" +# Output: London: ⛅️ +8°C +``` + +Compact format: +```bash +curl -s "wttr.in/London?format=%l:+%c+%t+%h+%w" +# Output: London: ⛅️ +8°C 71% ↙5km/h +``` + +Full forecast: +```bash +curl -s "wttr.in/London?T" +``` + +Format codes: `%c` condition · `%t` temp · `%h` humidity · `%w` wind · `%l` location · `%m` moon + +Tips: +- URL-encode spaces: `wttr.in/New+York` +- Airport codes: `wttr.in/JFK` +- Units: `?m` (metric) `?u` (USCS) +- Today only: `?1` · Current only: `?0` +- PNG: `curl -s "wttr.in/Berlin.png" -o /tmp/weather.png` + +## Open-Meteo (fallback, JSON) + +Free, no key, good for programmatic use: +```bash +curl -s "https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.12¤t_weather=true" +``` + +Find coordinates for a city, then query. Returns JSON with temp, windspeed, weathercode. + +Docs: https://open-meteo.com/en/docs From 1d12a844c27817fa2954c3bf0027c6c0cb1db22c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 16:34:27 +0000 Subject: [PATCH 41/44] docs: add WhatsApp disconnect workaround to FAQ When using macOS app with WhatsApp issues: 1. Run pnpm gateway:watch (Node instead of bun) 2. Enable 'External gateway' in app debug settings Verified gateway:watch command exists in package.json --- docs/faq.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/faq.md b/docs/faq.md index 50134fbee..aafcd012f 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -181,6 +181,19 @@ If you see WebSocket errors like `ws.WebSocket 'upgrade' event is not implemente **For debugging:** Use `pnpm gateway:watch` for live reload during development. +### WhatsApp keeps disconnecting / crashing (macOS app) + +This is often the bun WebSocket issue. Workaround: + +1. Run gateway with Node instead: + ```bash + pnpm gateway:watch + ``` +2. In **Clawdis.app → Settings → Debug**, check **"External gateway"** +3. The app now connects to your Node gateway instead of spawning bun + +This is the most stable setup until bun's WebSocket handling improves. + --- ## Multi-Instance & Contexts From d79dc4d74251415de3f339695921ae51c14fbf5f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 17:43:34 +0100 Subject: [PATCH 42/44] fix: correct camera snap mime mapping --- CHANGELOG.md | 1 + src/agents/clawdis-tools.ts | 7 ++++--- src/media/mime.test.ts | 11 ++++++++++- src/media/mime.ts | 19 +++++++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 228d580b7..aadfe9650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ - macOS: log health refresh failures and recovery to make gateway issues easier to diagnose. - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b - macOS codesign: include camera entitlement so permission prompts work in the menu bar app. +- Agent tools: map `camera.snap` JPEG payloads to `image/jpeg` to avoid MIME mismatch errors. - macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b - macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b - macOS remote: route settings through gateway config and avoid local config reads in remote mode. diff --git a/src/agents/clawdis-tools.ts b/src/agents/clawdis-tools.ts index 2b3a37647..65b776b0e 100644 --- a/src/agents/clawdis-tools.ts +++ b/src/agents/clawdis-tools.ts @@ -43,7 +43,7 @@ import { parseDurationMs } from "../cli/parse-duration.js"; import { loadConfig } from "../config/config.js"; import { reactMessageDiscord } from "../discord/send.js"; import { callGateway } from "../gateway/call.js"; -import { detectMime } from "../media/mime.js"; +import { detectMime, imageMimeFromFormat } from "../media/mime.js"; import { sanitizeToolResultImages } from "./tool-images.js"; // biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-ai uses a different module instance. @@ -875,7 +875,7 @@ function createCanvasTool(): AnyAgentTool { }); await writeBase64ToFile(filePath, payload.base64); const mimeType = - payload.format === "jpeg" ? "image/jpeg" : "image/png"; + imageMimeFromFormat(payload.format) ?? "image/png"; return await imageResult({ label: "canvas:snapshot", path: filePath, @@ -1141,7 +1141,8 @@ function createNodesTool(): AnyAgentTool { content.push({ type: "image", data: payload.base64, - mimeType: payload.format === "jpeg" ? "image/jpeg" : "image/png", + mimeType: + imageMimeFromFormat(payload.format) ?? "image/png", }); details.push({ facing, diff --git a/src/media/mime.test.ts b/src/media/mime.test.ts index 3eeb72a68..425a36a6b 100644 --- a/src/media/mime.test.ts +++ b/src/media/mime.test.ts @@ -1,7 +1,7 @@ import JSZip from "jszip"; import { describe, expect, it } from "vitest"; -import { detectMime } from "./mime.js"; +import { detectMime, imageMimeFromFormat } from "./mime.js"; async function makeOoxmlZip(opts: { mainMime: string; @@ -17,6 +17,15 @@ async function makeOoxmlZip(opts: { } describe("mime detection", () => { + it("maps common image formats to mime types", () => { + expect(imageMimeFromFormat("jpg")).toBe("image/jpeg"); + expect(imageMimeFromFormat("jpeg")).toBe("image/jpeg"); + expect(imageMimeFromFormat("png")).toBe("image/png"); + expect(imageMimeFromFormat("webp")).toBe("image/webp"); + expect(imageMimeFromFormat("gif")).toBe("image/gif"); + expect(imageMimeFromFormat("unknown")).toBeUndefined(); + }); + it("detects docx from buffer", async () => { const buf = await makeOoxmlZip({ mainMime: diff --git a/src/media/mime.ts b/src/media/mime.ts index 0f6bf057c..d26cfb969 100644 --- a/src/media/mime.ts +++ b/src/media/mime.ts @@ -107,6 +107,25 @@ export function extensionForMime(mime?: string | null): string | undefined { return EXT_BY_MIME[mime.toLowerCase()]; } +export function imageMimeFromFormat( + format?: string | null, +): string | undefined { + if (!format) return undefined; + switch (format.toLowerCase()) { + case "jpg": + case "jpeg": + return "image/jpeg"; + case "png": + return "image/png"; + case "webp": + return "image/webp"; + case "gif": + return "image/gif"; + default: + return undefined; + } +} + export function kindFromMime(mime?: string | null): MediaKind { return mediaKindFromMime(mime); } From 5f82739e2b36fd756169c32959937e7911c8ed53 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 17:49:20 +0100 Subject: [PATCH 43/44] test: cover camera snap mime mapping --- CHANGELOG.md | 1 + src/agents/clawdis-tools.camera.test.ts | 53 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/agents/clawdis-tools.camera.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aadfe9650..1185280cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b - macOS codesign: include camera entitlement so permission prompts work in the menu bar app. - Agent tools: map `camera.snap` JPEG payloads to `image/jpeg` to avoid MIME mismatch errors. +- Tests: cover `camera.snap` MIME mapping to prevent image/png vs image/jpeg mismatches. - macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b - macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b - macOS remote: route settings through gateway config and avoid local config reads in remote mode. diff --git a/src/agents/clawdis-tools.camera.test.ts b/src/agents/clawdis-tools.camera.test.ts new file mode 100644 index 000000000..d292f5ca9 --- /dev/null +++ b/src/agents/clawdis-tools.camera.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGateway = vi.fn(); + +vi.mock("../gateway/call.js", () => ({ callGateway })); +vi.mock("../media/image-ops.js", () => ({ + getImageMetadata: vi.fn(async () => ({ width: 1, height: 1 })), + resizeToJpeg: vi.fn(async () => Buffer.from("jpeg")), +})); + +import { createClawdisTools } from "./clawdis-tools.js"; + +describe("clawdis_nodes camera_snap", () => { + beforeEach(() => { + callGateway.mockReset(); + }); + + it("maps jpg payloads to image/jpeg", async () => { + callGateway.mockImplementation(async ({ method }) => { + if (method === "node.list") { + return { nodes: [{ nodeId: "mac-1" }] }; + } + if (method === "node.invoke") { + return { + payload: { + format: "jpg", + base64: "aGVsbG8=", + width: 1, + height: 1, + }, + }; + } + throw new Error(`unexpected method: ${String(method)}`); + }); + + const tool = createClawdisTools().find( + (candidate) => candidate.name === "clawdis_nodes", + ); + if (!tool) throw new Error("missing clawdis_nodes tool"); + + const result = await tool.execute("call1", { + action: "camera_snap", + node: "mac-1", + facing: "front", + }); + + const images = (result.content ?? []).filter( + (block) => block.type === "image", + ); + expect(images).toHaveLength(1); + expect(images[0]?.mimeType).toBe("image/jpeg"); + }); +}); From 34d2e1e2e826ea7723409a290100f49221f7e800 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 17:57:34 +0100 Subject: [PATCH 44/44] fix: wait for camera exposure to settle --- CHANGELOG.md | 1 + .../macos/Sources/Clawdis/CameraCaptureService.swift | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1185280cb..21151cea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ - macOS codesign: include camera entitlement so permission prompts work in the menu bar app. - Agent tools: map `camera.snap` JPEG payloads to `image/jpeg` to avoid MIME mismatch errors. - Tests: cover `camera.snap` MIME mapping to prevent image/png vs image/jpeg mismatches. +- macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images. - macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b - macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b - macOS remote: route settings through gateway config and avoid local config reads in remote mode. diff --git a/apps/macos/Sources/Clawdis/CameraCaptureService.swift b/apps/macos/Sources/Clawdis/CameraCaptureService.swift index 3c9d9c357..cf2d133d5 100644 --- a/apps/macos/Sources/Clawdis/CameraCaptureService.swift +++ b/apps/macos/Sources/Clawdis/CameraCaptureService.swift @@ -62,6 +62,7 @@ actor CameraCaptureService { session.startRunning() defer { session.stopRunning() } await Self.warmUpCaptureSession() + await self.waitForExposureAndWhiteBalance(device: device) let settings: AVCapturePhotoSettings = { if output.availablePhotoCodecTypes.contains(.jpeg) { @@ -257,6 +258,17 @@ actor CameraCaptureService { // A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices. try? await Task.sleep(nanoseconds: 150_000_000) // 150ms } + + private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async { + let stepNs: UInt64 = 50_000_000 + let maxSteps = 30 // ~1.5s + for _ in 0..