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/CHANGELOG.md b/CHANGELOG.md index 067ff1fb6..21151cea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,14 @@ - 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. - 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. @@ -43,11 +45,16 @@ ### 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. - 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. @@ -61,10 +68,17 @@ - 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: 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 +- 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. @@ -116,6 +130,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. diff --git a/README.md b/README.md index 538adfb2d..9f0dfd62d 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 @@ -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.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed. ```json5 { 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.. 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/ConfigSettings.swift b/apps/macos/Sources/Clawdis/ConfigSettings.swift index 1cd3f9569..bab1250e5 100644 --- a/apps/macos/Sources/Clawdis/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdis/ConfigSettings.swift @@ -431,12 +431,57 @@ 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 + } + } + + @MainActor + private 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 +490,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/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/GatewayChannel.swift b/apps/macos/Sources/Clawdis/GatewayChannel.swift index 4de00bd3e..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 @@ -214,6 +217,8 @@ actor GatewayChannelActor { ] if let token = self.token { 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 192eb3838..1c2aa841d 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,6 +17,7 @@ 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 @@ -24,11 +25,52 @@ actor GatewayEndpointStore { static let live = Deps( mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] }, + password: { + let root = ClawdisConfigFile.loadDict() + 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") @@ -47,9 +89,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: @@ -77,17 +121,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")) } @@ -110,8 +155,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]) @@ -122,9 +167,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)) @@ -144,7 +190,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 @@ -158,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 a5ee815f0..eeee7f344 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) @@ -71,9 +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 + \(escapedPassword) """ } let plist = """ @@ -146,6 +155,33 @@ 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 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 @@ -190,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/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..f8e8d5932 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -164,14 +164,24 @@ struct MenuContent: View { } private func saveBrowserControlEnabled(_ enabled: Bool) async { + let (success, _) = await MenuContent.buildAndSaveBrowserEnabled(enabled) + + if !success { + await self.loadBrowserControlEnabled() + } + } + + @MainActor + private 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, ()) } } @@ -365,6 +375,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..312744219 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,17 +189,14 @@ 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) + if let gatewayEntry = self.gatewayEntry() { + let gatewayItem = self.makeNodeItem(entry: gatewayEntry, width: width) + menu.insertItem(gatewayItem, 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( self.makeMessageItem( @@ -229,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 } @@ -265,27 +248,56 @@ 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 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 var shouldShowConnecting: Bool { - switch GatewayProcessManager.shared.status { - case .starting, .running, .attachedExisting: - return true - case .stopped, .failed: - return false - } + 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 { @@ -293,8 +305,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { 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) diff --git a/apps/macos/Sources/Clawdis/NodesMenu.swift b/apps/macos/Sources/Clawdis/NodesMenu.swift index 882b7ec3e..70635929b 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 { - entry.displayName?.nonEmpty ?? entry.nodeId + if self.isGateway(entry) { + return entry.displayName?.nonEmpty ?? "Gateway" + } + return 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") 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 + } } diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift b/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift index 8f02adc38..3157df615 100644 --- a/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift +++ b/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift @@ -75,6 +75,16 @@ extension OnboardingView { @discardableResult func saveAgentWorkspace(_ workspace: String?) async -> Bool { + let (success, errorMessage) = await OnboardingView.buildAndSaveWorkspace(workspace) + + if let errorMessage { + self.workspaceStatus = errorMessage + } + return success + } + + @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) ?? "" @@ -90,10 +100,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/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/Clawdis/TailscaleIntegrationSection.swift b/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift index f8cabf9c7..1621efdd3 100644 --- a/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift +++ b/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift @@ -280,28 +280,57 @@ 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() + } + + @MainActor + private 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 +348,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/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 { diff --git a/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift index 64f117ec2..19d730872 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,71 @@ 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 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 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 { @@ -83,6 +144,7 @@ import Testing let store = GatewayEndpointStore(deps: .init( mode: { mode.get() }, token: { nil }, + password: { nil }, localPort: { 18789 }, remotePortIfRunning: { nil }, ensureRemoteTunnel: { 18789 })) 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/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..7d7f30964 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"] } } ``` @@ -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) @@ -549,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" } } } @@ -560,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/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/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/faq.md b/docs/faq.md index 74e129c53..aafcd012f 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,31 @@ 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. + +### 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 @@ -213,6 +281,31 @@ 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](https://tailscale.com/)** to create a secure network between your 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. + +--- + ## Troubleshooting ### Build errors (TypeScript) @@ -246,6 +339,53 @@ 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 +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 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/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/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/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/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 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 new file mode 100644 index 000000000..1a6acb07b --- /dev/null +++ b/skills/local-places/SKILL.md @@ -0,0 +1,91 @@ +--- +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 + +*Find places, Go fast* + +Search for nearby places using a local Google Places API proxy. Two-step flow: resolve location first, then search. + +## Setup + +```bash +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. + +## 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. 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/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 000000000..0a17848a4 Binary files /dev/null and b/skills/local-places/src/local_places/__pycache__/__init__.cpython-314.pyc differ 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 000000000..94944facf Binary files /dev/null and b/skills/local-places/src/local_places/__pycache__/google_places.cpython-314.pyc differ diff --git a/skills/local-places/src/local_places/__pycache__/main.cpython-314.pyc b/skills/local-places/src/local_places/__pycache__/main.cpython-314.pyc new file mode 100644 index 000000000..ec25ea963 Binary files /dev/null and b/skills/local-places/src/local_places/__pycache__/main.cpython-314.pyc differ diff --git a/skills/local-places/src/local_places/__pycache__/schemas.cpython-314.pyc b/skills/local-places/src/local_places/__pycache__/schemas.cpython-314.pyc new file mode 100644 index 000000000..997365113 Binary files /dev/null and b/skills/local-places/src/local_places/__pycache__/schemas.cpython-314.pyc differ 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 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. 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 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"); + }); +}); 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/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/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..1b85bb096 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -841,14 +841,27 @@ 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 isEmptyConfig = Object.keys(cfg).length === 0; + if (isWhatsAppSurface && isEmptyConfig && from && to && from !== to) { + cleanupTyping(); + return undefined; + } const defaultAllowFrom = - (!configuredAllowFrom || configuredAllowFrom.length === 0) && to + isWhatsAppSurface && + (!configuredAllowFrom || configuredAllowFrom.length === 0) && + to ? [to] : undefined; const allowFrom = @@ -862,10 +875,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 +891,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/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/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 diff --git a/src/cli/program.ts b/src/cli/program.ts index b53f4137d..a6961d339 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -10,6 +10,7 @@ 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"; @@ -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. Run "clawdis doctor" (or ask your agent) 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..000e6c01c --- /dev/null +++ b/src/commands/doctor.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; + +const readConfigFileSnapshot = vi.fn(); +const writeConfigFile = vi.fn().mockResolvedValue(undefined); +const migrateLegacyConfig = vi.fn((raw: unknown) => ({ + config: raw as Record, + changes: ["Moved routing.allowFrom → whatsapp.allowFrom."], +})); + +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, + migrateLegacyConfig, +})); + +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(), + }; + + migrateLegacyConfig.mockReturnValue({ + config: { whatsapp: { allowFrom: ["+15555550123"] } }, + changes: ["Moved routing.allowFrom → whatsapp.allowFrom."], + }); + + 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..635bd28a0 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -4,6 +4,7 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import type { ClawdisConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDIS, + migrateLegacyConfig, readConfigFileSnapshot, writeConfigFile, } from "../config/config.js"; @@ -29,10 +30,42 @@ 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) { + // 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"); + } + if (migrated) { + cfg = migrated; + } + } + } + const workspaceDir = resolveUserPath( cfg.agent?.workspace ?? DEFAULT_WORKSPACE, ); @@ -57,7 +90,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) { @@ -79,7 +117,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/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index 39f9c4a53..4b14de7f3 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, + }, }, }; } @@ -482,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", ); @@ -498,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/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/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), })), ], }), diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 06b774fe7..6ababcbe4 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -488,3 +488,50 @@ 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("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"); + 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..d58142fcd 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 */ @@ -187,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; @@ -195,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; @@ -260,7 +277,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[]; @@ -335,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. */ @@ -525,6 +543,7 @@ export type ClawdisConfig = { messages?: MessagesConfig; session?: SessionConfig; web?: WebConfig; + whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; signal?: SignalConfig; @@ -693,7 +712,6 @@ const HeartbeatSchema = z const RoutingSchema = z .object({ - allowFrom: z.array(z.string()).optional(), groupChat: GroupChatSchema, transcribeAudio: TranscribeAudioSchema, queue: z @@ -909,6 +927,11 @@ const ClawdisSchema = z.object({ .optional(), }) .optional(), + whatsapp: z + .object({ + allowFrom: z.array(z.string()).optional(), + }) + .optional(), telegram: z .object({ enabled: z.boolean().optional(), @@ -927,6 +950,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(), @@ -1068,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(), }) @@ -1131,6 +1163,11 @@ export type ConfigValidationIssue = { message: string; }; +export type LegacyConfigIssue = { + path: string; + message: string; +}; + export type ConfigFileSnapshot = { path: string; exists: boolean; @@ -1139,8 +1176,102 @@ export type ConfigFileSnapshot = { valid: boolean; config: ClawdisConfig; issues: ConfigValidationIssue[]; + legacyIssues: LegacyConfigIssue[]; }; +type LegacyConfigRule = { + path: string[]; + message: string; +}; + +type LegacyConfigMigration = { + id: string; + describe: string; + apply: (raw: Record, changes: string[]) => void; +}; + +const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ + { + path: ["routing", "allowFrom"], + message: + "routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdis doctor` to migrate).", + }, +]; + +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; + 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; +} + +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, "\\$&"); } @@ -1199,6 +1330,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 +1412,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 +1421,7 @@ export async function readConfigFileSnapshot(): Promise { valid: true, config, issues: [], + legacyIssues, }; } @@ -1296,9 +1439,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 +1455,7 @@ export async function readConfigFileSnapshot(): Promise { valid: false, config: {}, issues: validated.issues, + legacyIssues, }; } @@ -1320,6 +1467,7 @@ export async function readConfigFileSnapshot(): Promise { valid: true, config: applyTalkApiKey(validated.config), issues: [], + legacyIssues, }; } catch (err) { return { @@ -1330,6 +1478,7 @@ export async function readConfigFileSnapshot(): Promise { valid: false, config: {}, issues: [{ path: "", message: `read failed: ${String(err)}` }], + legacyIssues: [], }; } } 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/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/discord/monitor.ts b/src/discord/monitor.ts index 78c5708ea..e5449074a 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,6 +1,8 @@ import { + ApplicationCommandOptionType, ChannelType, Client, + type CommandInteractionOption, Events, GatewayIntentBits, type Message, @@ -10,10 +12,12 @@ import { import { chunkText } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.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 } 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"; @@ -25,6 +29,7 @@ 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,163 @@ 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 +782,86 @@ 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 +907,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/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..32399258e 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,14 +39,20 @@ 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() : 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); diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index cfac4af86..86f21aaa1 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[] = []; +const testIsNixMode = vi.hoisted(() => ({ value: 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); @@ -163,6 +183,7 @@ vi.mock("../config/config.js", () => { valid: true, config: {}, issues: [], + legacyIssues: [], }; } try { @@ -176,6 +197,7 @@ vi.mock("../config/config.js", () => { valid: true, config: parsed, issues: [], + legacyIssues: [], }; } catch (err) { return { @@ -186,27 +208,34 @@ vi.mock("../config/config.js", () => { valid: false, config: {}, issues: [{ path: "", message: `read failed: ${String(err)}` }], + legacyIssues: [], }; } }; - 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, + get isNixMode() { + return testIsNixMode.value; + }, + migrateLegacyConfig: (raw: unknown) => ({ + config: testMigrationConfig ?? (raw as Record), + changes: testMigrationChanges, + }), loadConfig: () => ({ agent: { model: "anthropic/claude-opus-4-5", workspace: path.join(os.tmpdir(), "clawd-gateway-test"), }, - routing: { + whatsapp: { allowFrom: testAllowFrom, }, session: { mainKey: "main", store: testSessionStorePath }, @@ -279,6 +308,11 @@ beforeEach(async () => { testGatewayAuth = undefined; testHooksConfig = undefined; testCanvasHostPort = undefined; + testLegacyIssues = []; + testLegacyParsed = {}; + testMigrationConfig = null; + testMigrationChanges = []; + testIsNixMode.value = false; cronIsolatedRun.mockClear(); drainSystemEvents(); resetAgentRunContextForTest(); @@ -516,6 +550,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.value = 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 = [ diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 6cebf0577..b27b29f25 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, @@ -659,8 +660,6 @@ type DedupeEntry = { error?: ErrorShape; }; -const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN; - function formatForLog(value: unknown): string { try { if (value instanceof Error) { @@ -1322,6 +1321,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); @@ -1345,7 +1369,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"] = @@ -2228,6 +2253,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, }) @@ -6641,7 +6667,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/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); } 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: ["*"], }, 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(); 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(""); + }); });