Merge branch 'steipete:main' into main

This commit is contained in:
Dan 2026-01-02 19:00:21 +02:00 committed by GitHub
commit b92f70c52b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
95 changed files with 2590 additions and 256 deletions

1
.gitignore vendored
View File

@ -45,3 +45,4 @@ apps/ios/*.dSYM.zip
# provisioning profiles (local) # provisioning profiles (local)
apps/ios/*.mobileprovision apps/ios/*.mobileprovision
.env

View File

@ -14,12 +14,14 @@
- Sessions: group keys now use `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` keys migrate on next message; `groupdm` keys are no longer recognized. - Sessions: group keys now use `surface:group:<id>` / `surface:channel:<id>`; 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`. - 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`. - 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 ### Features
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech. - 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). - 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). - 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`). - 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. - 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. - 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. - iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.
@ -43,11 +45,16 @@
### Fixes ### Fixes
- Chat UI: keep the chat scrolled to the latest message after switching sessions. - 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). - 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). - 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. - 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 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. - 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: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.
- Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs. - 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. - 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: 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: 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: 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). - 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 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: 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 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: 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. - 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 isnt open. - iOS/Android Talk Mode: explicitly `chat.subscribe` when Talk Mode is active, so completion events arrive even if the Chat UI isnt open.
- Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently. - 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: `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. - 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. - 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. - Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast.

View File

@ -157,7 +157,7 @@ Minimal `~/.clawdis/clawdis.json`:
```json5 ```json5
{ {
routing: { whatsapp: {
allowFrom: ["+1234567890"] allowFrom: ["+1234567890"]
} }
} }
@ -166,7 +166,7 @@ Minimal `~/.clawdis/clawdis.json`:
### WhatsApp ### WhatsApp
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`). - 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 ### Telegram
@ -184,7 +184,7 @@ Minimal `~/.clawdis/clawdis.json`:
### Discord ### Discord
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins). - 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 ```json5
{ {

View File

@ -62,6 +62,7 @@ actor CameraCaptureService {
session.startRunning() session.startRunning()
defer { session.stopRunning() } defer { session.stopRunning() }
await Self.warmUpCaptureSession() await Self.warmUpCaptureSession()
await self.waitForExposureAndWhiteBalance(device: device)
let settings: AVCapturePhotoSettings = { let settings: AVCapturePhotoSettings = {
if output.availablePhotoCodecTypes.contains(.jpeg) { 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. // A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms 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..<maxSteps {
if !(device.isAdjustingExposure || device.isAdjustingWhiteBalance) {
return
}
try? await Task.sleep(nanoseconds: stepNs)
}
}
} }
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate { private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {

View File

@ -97,4 +97,13 @@ enum ClawdisConfigFile {
self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)") self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)")
} }
static func gatewayPassword() -> String? {
let root = self.loadDict()
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any] else {
return nil
}
return remote["password"] as? String
}
} }

View File

@ -431,12 +431,57 @@ struct ConfigSettings: View {
self.configSaving = true self.configSaving = true
defer { self.configSaving = false } 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 root = await ConfigStore.load()
var agent = root["agent"] as? [String: Any] ?? [:] var agent = root["agent"] as? [String: Any] ?? [:]
var browser = root["browser"] as? [String: Any] ?? [:] var browser = root["browser"] as? [String: Any] ?? [:]
var talk = root["talk"] 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) .trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedModel = chosenModel let trimmedModel = chosenModel
if !trimmedModel.isEmpty { agent["model"] = trimmedModel } if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
@ -445,40 +490,41 @@ struct ConfigSettings: View {
agent["heartbeatMinutes"] = heartbeatMinutes agent["heartbeatMinutes"] = heartbeatMinutes
} }
let trimmedBody = self.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedBody = heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedBody.isEmpty { if !trimmedBody.isEmpty {
agent["heartbeatBody"] = trimmedBody agent["heartbeatBody"] = trimmedBody
} }
root["agent"] = agent root["agent"] = agent
browser["enabled"] = self.browserEnabled browser["enabled"] = browserEnabled
let trimmedUrl = self.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedUrl = browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl } 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 } if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
browser["attachOnly"] = self.browserAttachOnly browser["attachOnly"] = browserAttachOnly
root["browser"] = browser root["browser"] = browser
let trimmedVoice = self.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedVoice = talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedVoice.isEmpty { if trimmedVoice.isEmpty {
talk.removeValue(forKey: "voiceId") talk.removeValue(forKey: "voiceId")
} else { } else {
talk["voiceId"] = trimmedVoice talk["voiceId"] = trimmedVoice
} }
let trimmedApiKey = self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedApiKey = talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedApiKey.isEmpty { if trimmedApiKey.isEmpty {
talk.removeValue(forKey: "apiKey") talk.removeValue(forKey: "apiKey")
} else { } else {
talk["apiKey"] = trimmedApiKey talk["apiKey"] = trimmedApiKey
} }
talk["interruptOnSpeech"] = self.talkInterruptOnSpeech talk["interruptOnSpeech"] = talkInterruptOnSpeech
root["talk"] = talk root["talk"] = talk
do { do {
try await ConfigStore.save(root) try await ConfigStore.save(root)
} catch { return nil
self.modelError = error.localizedDescription } catch let error {
return error.localizedDescription
} }
} }

View File

@ -43,7 +43,7 @@ enum ConfigStore {
} }
@MainActor @MainActor
static func save(_ root: [String: Any]) async throws { static func save(_ root: sending [String: Any]) async throws {
let overrides = await self.overrideStore.overrides let overrides = await self.overrideStore.overrides
if await self.isRemoteMode() { if await self.isRemoteMode() {
if let override = overrides.saveRemote { if let override = overrides.saveRemote {

View File

@ -63,9 +63,11 @@ final class ControlChannel {
self.logger.info("control channel state -> connecting") self.logger.info("control channel state -> connecting")
case .disconnected: case .disconnected:
self.logger.info("control channel state -> disconnected") self.logger.info("control channel state -> disconnected")
self.scheduleRecovery(reason: "disconnected")
case let .degraded(message): case let .degraded(message):
let detail = message.isEmpty ? "degraded" : "degraded: \(message)" let detail = message.isEmpty ? "degraded" : "degraded: \(message)"
self.logger.info("control channel state -> \(detail, privacy: .public)") 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 let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
private var eventTask: Task<Void, Never>? private var eventTask: Task<Void, Never>?
private var recoveryTask: Task<Void, Never>?
private var lastRecoveryAt: Date?
private init() { private init() {
self.startEventStream() self.startEventStream()
@ -231,7 +235,43 @@ final class ControlChannel {
} }
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription 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 { func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {

View File

@ -66,6 +66,7 @@ actor GatewayChannelActor {
private var connectWaiters: [CheckedContinuation<Void, Error>] = [] private var connectWaiters: [CheckedContinuation<Void, Error>] = []
private var url: URL private var url: URL
private var token: String? private var token: String?
private var password: String?
private let session: WebSocketSessioning private let session: WebSocketSessioning
private var backoffMs: Double = 500 private var backoffMs: Double = 500
private var shouldReconnect = true private var shouldReconnect = true
@ -82,11 +83,13 @@ actor GatewayChannelActor {
init( init(
url: URL, url: URL,
token: String?, token: String?,
password: String? = nil,
session: WebSocketSessionBox? = nil, session: WebSocketSessionBox? = nil,
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil) pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil)
{ {
self.url = url self.url = url
self.token = token self.token = token
self.password = password
self.session = session?.session ?? URLSession(configuration: .default) self.session = session?.session ?? URLSession(configuration: .default)
self.pushHandler = pushHandler self.pushHandler = pushHandler
Task { [weak self] in Task { [weak self] in
@ -214,6 +217,8 @@ actor GatewayChannelActor {
] ]
if let token = self.token { if let token = self.token {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)]) params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
} else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
} }
let frame = RequestFrame( let frame = RequestFrame(

View File

@ -40,7 +40,7 @@ struct GatewayAgentInvocation: Sendable {
actor GatewayConnection { actor GatewayConnection {
static let shared = GatewayConnection() static let shared = GatewayConnection()
typealias Config = (url: URL, token: String?) typealias Config = (url: URL, token: String?, password: String?)
enum Method: String, Sendable { enum Method: String, Sendable {
case agent case agent
@ -83,6 +83,7 @@ actor GatewayConnection {
private var client: GatewayChannelActor? private var client: GatewayChannelActor?
private var configuredURL: URL? private var configuredURL: URL?
private var configuredToken: String? private var configuredToken: String?
private var configuredPassword: String?
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:] private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
private var lastSnapshot: HelloOk? private var lastSnapshot: HelloOk?
@ -103,7 +104,7 @@ actor GatewayConnection {
timeoutMs: Double? = nil) async throws -> Data timeoutMs: Double? = nil) async throws -> Data
{ {
let cfg = try await self.configProvider() 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 { guard let client else {
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) 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) try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do { do {
let cfg = try await self.configProvider() 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 { guard let client = self.client else {
throw NSError( throw NSError(
domain: "Gateway", domain: "Gateway",
@ -209,7 +210,7 @@ actor GatewayConnection {
/// Ensure the underlying socket is configured (and replaced if config changed). /// Ensure the underlying socket is configured (and replaced if config changed).
func refresh() async throws { func refresh() async throws {
let cfg = try await self.configProvider() 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 { func shutdown() async {
@ -264,8 +265,8 @@ actor GatewayConnection {
} }
} }
private func configure(url: URL, token: String?) async { private func configure(url: URL, token: String?, password: String?) async {
if self.client != nil, self.configuredURL == url, self.configuredToken == token { if self.client != nil, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password {
return return
} }
if let client { if let client {
@ -275,12 +276,14 @@ actor GatewayConnection {
self.client = GatewayChannelActor( self.client = GatewayChannelActor(
url: url, url: url,
token: token, token: token,
password: password,
session: self.sessionBox, session: self.sessionBox,
pushHandler: { [weak self] push in pushHandler: { [weak self] push in
await self?.handle(push: push) await self?.handle(push: push)
}) })
self.configuredURL = url self.configuredURL = url
self.configuredToken = token self.configuredToken = token
self.configuredPassword = password
} }
private func handle(push: GatewayPush) { private func handle(push: GatewayPush) {

View File

@ -2,7 +2,7 @@ import Foundation
import OSLog import OSLog
enum GatewayEndpointState: Sendable, Equatable { 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) case unavailable(mode: AppState.ConnectionMode, reason: String)
} }
@ -17,6 +17,7 @@ actor GatewayEndpointStore {
struct Deps: Sendable { struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode let mode: @Sendable () async -> AppState.ConnectionMode
let token: @Sendable () -> String? let token: @Sendable () -> String?
let password: @Sendable () -> String?
let localPort: @Sendable () -> Int let localPort: @Sendable () -> Int
let remotePortIfRunning: @Sendable () async -> UInt16? let remotePortIfRunning: @Sendable () async -> UInt16?
let ensureRemoteTunnel: @Sendable () async throws -> UInt16 let ensureRemoteTunnel: @Sendable () async throws -> UInt16
@ -24,11 +25,52 @@ actor GatewayEndpointStore {
static let live = Deps( static let live = Deps(
mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] }, 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() }, localPort: { GatewayEnvironment.gatewayPort() },
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() }) 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 deps: Deps
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway-endpoint") private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway-endpoint")
@ -47,9 +89,11 @@ actor GatewayEndpointStore {
} }
let port = deps.localPort() let port = deps.localPort()
let token = deps.token()
let password = deps.password()
switch initialMode { switch initialMode {
case .local: 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: case .remote:
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel") self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
case .unconfigured: case .unconfigured:
@ -77,17 +121,18 @@ actor GatewayEndpointStore {
func setMode(_ mode: AppState.ConnectionMode) async { func setMode(_ mode: AppState.ConnectionMode) async {
let token = self.deps.token() let token = self.deps.token()
let password = self.deps.password()
switch mode { switch mode {
case .local: case .local:
let port = self.deps.localPort() 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: case .remote:
let port = await self.deps.remotePortIfRunning() let port = await self.deps.remotePortIfRunning()
guard let port else { guard let port else {
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")) self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
return 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: case .unconfigured:
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
} }
@ -110,8 +155,8 @@ actor GatewayEndpointStore {
func requireConfig() async throws -> GatewayConnection.Config { func requireConfig() async throws -> GatewayConnection.Config {
await self.refresh() await self.refresh()
switch self.state { switch self.state {
case let .ready(_, url, token): case let .ready(_, url, token, password):
return (url, token) return (url, token, password)
case let .unavailable(mode, reason): case let .unavailable(mode, reason):
guard mode == .remote else { guard mode == .remote else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason]) throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
@ -122,9 +167,10 @@ actor GatewayEndpointStore {
do { do {
let forwarded = try await self.deps.ensureRemoteTunnel() let forwarded = try await self.deps.ensureRemoteTunnel()
let token = self.deps.token() let token = self.deps.token()
let password = self.deps.password()
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")! let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
self.setState(.ready(mode: .remote, url: url, token: token)) self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return (url, token) return (url, token, password)
} catch { } catch {
let msg = "\(reason) (\(error.localizedDescription))" let msg = "\(reason) (\(error.localizedDescription))"
self.setState(.unavailable(mode: .remote, reason: msg)) self.setState(.unavailable(mode: .remote, reason: msg))
@ -144,7 +190,7 @@ actor GatewayEndpointStore {
continuation.yield(next) continuation.yield(next)
} }
switch next { switch next {
case let .ready(mode, url, _): case let .ready(mode, url, _, _):
let modeDesc = String(describing: mode) let modeDesc = String(describing: mode)
let urlDesc = url.absoluteString let urlDesc = url.absoluteString
self.logger 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

View File

@ -64,6 +64,7 @@ enum GatewayLaunchAgentManager {
.joined(separator: ":") .joined(separator: ":")
let bind = self.preferredGatewayBind() ?? "loopback" let bind = self.preferredGatewayBind() ?? "loopback"
let token = self.preferredGatewayToken() let token = self.preferredGatewayToken()
let password = self.preferredGatewayPassword()
var envEntries = """ var envEntries = """
<key>PATH</key> <key>PATH</key>
<string>\(preferredPath)</string> <string>\(preferredPath)</string>
@ -71,9 +72,17 @@ enum GatewayLaunchAgentManager {
<string>sips</string> <string>sips</string>
""" """
if let token { if let token {
let escapedToken = self.escapePlistValue(token)
envEntries += """ envEntries += """
<key>CLAWDIS_GATEWAY_TOKEN</key> <key>CLAWDIS_GATEWAY_TOKEN</key>
<string>\(token)</string> <string>\(escapedToken)</string>
"""
}
if let password {
let escapedPassword = self.escapePlistValue(password)
envEntries += """
<key>CLAWDIS_GATEWAY_PASSWORD</key>
<string>\(escapedPassword)</string>
""" """
} }
let plist = """ let plist = """
@ -146,6 +155,33 @@ enum GatewayLaunchAgentManager {
return trimmed.isEmpty ? nil : trimmed 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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}
private struct LaunchctlResult { private struct LaunchctlResult {
let status: Int32 let status: Int32
let output: String let output: String
@ -190,5 +226,9 @@ extension GatewayLaunchAgentManager {
static func _testPreferredGatewayToken() -> String? { static func _testPreferredGatewayToken() -> String? {
self.preferredGatewayToken() self.preferredGatewayToken()
} }
static func _testEscapePlistValue(_ raw: String) -> String {
self.escapePlistValue(raw)
}
} }
#endif #endif

View File

@ -114,6 +114,7 @@ final class HealthStore {
guard !self.isRefreshing else { return } guard !self.isRefreshing else { return }
self.isRefreshing = true self.isRefreshing = true
defer { self.isRefreshing = false } defer { self.isRefreshing = false }
let previousError = self.lastError
do { do {
let data = try await ControlChannel.shared.health(timeout: 15) let data = try await ControlChannel.shared.health(timeout: 15)
@ -121,13 +122,23 @@ final class HealthStore {
self.snapshot = decoded self.snapshot = decoded
self.lastSuccess = Date() self.lastSuccess = Date()
self.lastError = nil self.lastError = nil
if previousError != nil {
Self.logger.info("health refresh recovered")
}
} else { } else {
self.lastError = "health output not JSON" self.lastError = "health output not JSON"
if onDemand { self.snapshot = nil } if onDemand { self.snapshot = nil }
if previousError != self.lastError {
Self.logger.warning("health refresh failed: output not JSON")
}
} }
} catch { } catch {
self.lastError = error.localizedDescription let desc = error.localizedDescription
self.lastError = desc
if onDemand { self.snapshot = nil } if onDemand { self.snapshot = nil }
if previousError != desc {
Self.logger.error("health refresh failed \(desc, privacy: .public)")
}
} }
} }

View File

@ -164,14 +164,24 @@ struct MenuContent: View {
} }
private func saveBrowserControlEnabled(_ enabled: Bool) async { 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 root = await ConfigStore.load()
var browser = root["browser"] as? [String: Any] ?? [:] var browser = root["browser"] as? [String: Any] ?? [:]
browser["enabled"] = enabled browser["enabled"] = enabled
root["browser"] = browser root["browser"] = browser
do { do {
try await ConfigStore.save(root) try await ConfigStore.save(root)
return (true, ())
} catch { } catch {
await self.loadBrowserControlEnabled() return (false, ())
} }
} }
@ -365,6 +375,10 @@ struct MenuContent: View {
Text(label) Text(label)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.layoutPriority(1)
} }
.padding(.top, 2) .padding(.top, 2)
} }

View File

@ -106,13 +106,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard let insertIndex = self.findInsertIndex(in: menu) else { return } guard let insertIndex = self.findInsertIndex(in: menu) else { return }
let width = self.initialWidth(for: menu) let width = self.initialWidth(for: menu)
guard self.isControlChannelConnected else { guard self.isControlChannelConnected else { return }
menu.insertItem(self.makeMessageItem(
text: self.controlChannelStatusText,
symbolName: "wifi.slash",
width: width), at: insertIndex)
return
}
guard let snapshot = self.cachedSnapshot else { guard let snapshot = self.cachedSnapshot else {
let headerItem = NSMenuItem() let headerItem = NSMenuItem()
@ -195,17 +189,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
menu.insertItem(topSeparator, at: cursor) menu.insertItem(topSeparator, at: cursor)
cursor += 1 cursor += 1
guard self.isControlChannelConnected else { if let gatewayEntry = self.gatewayEntry() {
menu.insertItem( let gatewayItem = self.makeNodeItem(entry: gatewayEntry, width: width)
self.makeMessageItem(text: self.controlChannelStatusText, symbolName: "wifi.slash", width: width), menu.insertItem(gatewayItem, at: cursor)
at: cursor)
cursor += 1 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 { if let error = self.nodesStore.lastError?.nonEmpty {
menu.insertItem( menu.insertItem(
self.makeMessageItem( self.makeMessageItem(
@ -229,15 +220,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
cursor += 1 cursor += 1
} else { } else {
for entry in entries.prefix(8) { for entry in entries.prefix(8) {
let item = NSMenuItem() let item = self.makeNodeItem(entry: entry, width: width)
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)
menu.insertItem(item, at: cursor) menu.insertItem(item, at: cursor)
cursor += 1 cursor += 1
} }
@ -265,27 +248,56 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
return false return false
} }
private var controlChannelStatusText: String { private func gatewayEntry() -> NodeInfo? {
switch ControlChannel.shared.state { let mode = AppStateStore.shared.connectionMode
case .connected: let isConnected = self.isControlChannelConnected
return "Connected" let port = GatewayEnvironment.gatewayPort()
case .connecting: var host: String?
return "Connecting to gateway…" var platform: String?
case let .degraded(reason):
if self.shouldShowConnecting { return "Connecting to gateway…" } switch mode {
return reason.nonEmpty ?? "No connection to gateway" case .remote:
case .disconnected: platform = "remote"
return self.shouldShowConnecting ? "Connecting to gateway…" : "No connection to gateway" 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 { private func makeNodeItem(entry: NodeInfo, width: CGFloat) -> NSMenuItem {
switch GatewayProcessManager.shared.status { let item = NSMenuItem()
case .starting, .running, .attachedExisting: item.tag = self.nodesTag
return true item.target = self
case .stopped, .failed: item.action = #selector(self.copyNodeSummary(_:))
return false 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 { private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
@ -293,8 +305,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
Label(text, systemImage: symbolName) Label(text, systemImage: symbolName)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .multilineTextAlignment(.leading)
.truncationMode(.tail) .lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 18) .padding(.leading, 18)
.padding(.trailing, 12) .padding(.trailing, 12)
.padding(.vertical, 6) .padding(.vertical, 6)

View File

@ -2,15 +2,30 @@ import AppKit
import SwiftUI import SwiftUI
struct NodeMenuEntryFormatter { struct NodeMenuEntryFormatter {
static func isGateway(_ entry: NodeInfo) -> Bool {
entry.nodeId == "gateway"
}
static func isConnected(_ entry: NodeInfo) -> Bool { static func isConnected(_ entry: NodeInfo) -> Bool {
entry.isConnected entry.isConnected
} }
static func primaryName(_ entry: NodeInfo) -> String { 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 { 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) let name = self.primaryName(entry)
var prefix = "Node: \(name)" var prefix = "Node: \(name)"
if let ip = entry.remoteIp?.nonEmpty { if let ip = entry.remoteIp?.nonEmpty {
@ -112,6 +127,11 @@ struct NodeMenuEntryFormatter {
} }
static func leadingSymbol(_ entry: NodeInfo) -> String { 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 let family = entry.deviceFamily?.lowercased() {
if family.contains("mac") { if family.contains("mac") {
return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer")

View File

@ -75,10 +75,26 @@ final class NodesStore {
self.lastError = nil self.lastError = nil
self.statusMessage = nil self.statusMessage = nil
} catch { } 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.logger.error("node.list failed \(error.localizedDescription, privacy: .public)")
self.nodes = [] self.nodes = []
self.lastError = error.localizedDescription self.lastError = error.localizedDescription
self.statusMessage = nil 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
}
} }

View File

@ -75,6 +75,16 @@ extension OnboardingView {
@discardableResult @discardableResult
func saveAgentWorkspace(_ workspace: String?) async -> Bool { 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 root = await ConfigStore.load()
var agent = root["agent"] as? [String: Any] ?? [:] var agent = root["agent"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@ -90,10 +100,10 @@ extension OnboardingView {
} }
do { do {
try await ConfigStore.save(root) try await ConfigStore.save(root)
return true return (true, nil)
} catch { } catch let error {
self.workspaceStatus = "Failed to save config: \(error.localizedDescription)" let errorMessage = "Failed to save config: \(error.localizedDescription)"
return false return (false, errorMessage)
} }
} }
} }

View File

@ -31,6 +31,8 @@ enum PermissionManager {
await self.ensureMicrophone(interactive: interactive) await self.ensureMicrophone(interactive: interactive)
case .speechRecognition: case .speechRecognition:
await self.ensureSpeechRecognition(interactive: interactive) await self.ensureSpeechRecognition(interactive: interactive)
case .camera:
await self.ensureCamera(interactive: interactive)
} }
} }
@ -114,6 +116,24 @@ enum PermissionManager {
return SFSpeechRecognizer.authorizationStatus() == .authorized 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 { static func voiceWakePermissionsGranted() -> Bool {
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
@ -153,6 +173,9 @@ enum PermissionManager {
case .speechRecognition: case .speechRecognition:
results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized
case .camera:
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
} }
} }
return results 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 { enum AppleScriptPermission {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "AppleScriptPermission") private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "AppleScriptPermission")

View File

@ -120,6 +120,7 @@ struct PermissionRow: View {
case .screenRecording: "Screen Recording" case .screenRecording: "Screen Recording"
case .microphone: "Microphone" case .microphone: "Microphone"
case .speechRecognition: "Speech Recognition" case .speechRecognition: "Speech Recognition"
case .camera: "Camera"
} }
} }
@ -132,6 +133,7 @@ struct PermissionRow: View {
case .screenRecording: "Capture the screen for context or screenshots" case .screenRecording: "Capture the screen for context or screenshots"
case .microphone: "Allow Voice Wake and audio capture" case .microphone: "Allow Voice Wake and audio capture"
case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device" 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 .screenRecording: "display"
case .microphone: "mic" case .microphone: "mic"
case .speechRecognition: "waveform" case .speechRecognition: "waveform"
case .camera: "camera"
} }
} }
} }

View File

@ -280,28 +280,57 @@ struct TailscaleIntegrationSection: View {
return 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 root = await ConfigStore.load()
var gateway = root["gateway"] as? [String: Any] ?? [:] var gateway = root["gateway"] as? [String: Any] ?? [:]
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:] var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
tailscale["mode"] = self.tailscaleMode.rawValue tailscale["mode"] = tailscaleMode.rawValue
gateway["tailscale"] = tailscale gateway["tailscale"] = tailscale
if self.tailscaleMode != .off { if tailscaleMode != .off {
gateway["bind"] = "loopback" gateway["bind"] = "loopback"
} }
if self.tailscaleMode == .off { if tailscaleMode == .off {
gateway.removeValue(forKey: "auth") gateway.removeValue(forKey: "auth")
} else { } else {
var auth = gateway["auth"] as? [String: Any] ?? [:] var auth = gateway["auth"] as? [String: Any] ?? [:]
if self.tailscaleMode == .serve, !self.requireCredentialsForServe { if tailscaleMode == .serve, !requireCredentialsForServe {
auth["allowTailscale"] = true auth["allowTailscale"] = true
auth.removeValue(forKey: "mode") auth.removeValue(forKey: "mode")
auth.removeValue(forKey: "password") auth.removeValue(forKey: "password")
} else { } else {
auth["allowTailscale"] = false auth["allowTailscale"] = false
auth["mode"] = "password" auth["mode"] = "password"
auth["password"] = trimmedPassword auth["password"] = password
} }
if auth.isEmpty { if auth.isEmpty {
@ -319,17 +348,10 @@ struct TailscaleIntegrationSection: View {
do { do {
try await ConfigStore.save(root) try await ConfigStore.save(root)
} catch { return (true, nil)
self.statusMessage = error.localizedDescription } catch let error {
return 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() { private func restartGatewayIfNeeded() {

View File

@ -11,6 +11,7 @@ public enum Capability: String, Codable, CaseIterable, Sendable {
case screenRecording case screenRecording
case microphone case microphone
case speechRecognition case speechRecognition
case camera
} }
public enum CameraFacing: String, Codable, Sendable { public enum CameraFacing: String, Codable, Sendable {

View File

@ -29,6 +29,7 @@ import Testing
let store = GatewayEndpointStore(deps: .init( let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() }, mode: { mode.get() },
token: { "t" }, token: { "t" },
password: { nil },
localPort: { 1234 }, localPort: { 1234 },
remotePortIfRunning: { nil }, remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 })) ensureRemoteTunnel: { 18789 }))
@ -44,6 +45,7 @@ import Testing
let store = GatewayEndpointStore(deps: .init( let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() }, mode: { mode.get() },
token: { nil }, token: { nil },
password: { nil },
localPort: { 18789 }, localPort: { 18789 },
remotePortIfRunning: { nil }, remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 })) ensureRemoteTunnel: { 18789 }))
@ -58,6 +60,7 @@ import Testing
let store = GatewayEndpointStore(deps: .init( let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() }, mode: { mode.get() },
token: { "tok" }, token: { "tok" },
password: { "pw" },
localPort: { 1 }, localPort: { 1 },
remotePortIfRunning: { 5555 }, remotePortIfRunning: { 5555 },
ensureRemoteTunnel: { 5555 })) ensureRemoteTunnel: { 5555 }))
@ -69,13 +72,71 @@ import Testing
_ = try await store.ensureRemoteControlTunnel() _ = try await store.ensureRemoteControlTunnel()
let next = await iterator.next() 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))") Issue.record("expected .ready after ensure, got \(String(describing: next))")
return return
} }
#expect(mode == .remote) #expect(mode == .remote)
#expect(url.absoluteString == "ws://127.0.0.1:5555") #expect(url.absoluteString == "ws://127.0.0.1:5555")
#expect(token == "tok") #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 { @Test func unconfiguredModeRejectsConfig() async {
@ -83,6 +144,7 @@ import Testing
let store = GatewayEndpointStore(deps: .init( let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() }, mode: { mode.get() },
token: { nil }, token: { nil },
password: { nil },
localPort: { 18789 }, localPort: { 18789 },
remotePortIfRunning: { nil }, remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 })) ensureRemoteTunnel: { 18789 }))

View File

@ -114,6 +114,9 @@ struct LowCoverageHelperTests {
setenv(keyToken, " secret ", 1) setenv(keyToken, " secret ", 1)
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan") #expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret") #expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
#expect(
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
"a&amp;b&lt;c&gt;&quot;&apos;")
#expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdis") #expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdis")
#expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay") #expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")

View File

@ -76,7 +76,7 @@ Incoming user messages are queued while the agent is streaming. The queue is che
At minimum, set: At minimum, set:
- `agent.workspace` - `agent.workspace`
- `routing.allowFrom` (strongly recommended) - `whatsapp.allowFrom` (strongly recommended)
--- ---

View File

@ -17,7 +17,7 @@ Youre putting an agent in a position to:
- send messages back out via WhatsApp/Telegram/Discord - send messages back out via WhatsApp/Telegram/Discord
Start conservative: 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. - 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"`). - 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 ```json5
{ {
routing: { whatsapp: {
allowFrom: ["+15555550123"] allowFrom: ["+15555550123"]
} }
} }
@ -124,8 +124,10 @@ Example:
// Start with 0; enable later. // Start with 0; enable later.
heartbeat: { every: "0m" } heartbeat: { every: "0m" }
}, },
whatsapp: {
allowFrom: ["+15555550123"]
},
routing: { routing: {
allowFrom: ["+15555550123"],
groupChat: { groupChat: {
requireMention: true, requireMention: true,
mentionPatterns: ["@clawd", "clawd"] mentionPatterns: ["@clawd", "clawd"]

View File

@ -9,7 +9,7 @@ read_when:
CLAWDIS reads an optional **JSON5** config from `~/.clawdis/clawdis.json` (comments + trailing commas allowed). 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: 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`) - tune group mention behavior (`routing.groupChat`)
- customize message prefixes (`messages`) - customize message prefixes (`messages`)
- set the agents workspace (`agent.workspace`) - set the agents workspace (`agent.workspace`)
@ -21,7 +21,7 @@ If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-
```json5 ```json5
{ {
agent: { workspace: "~/clawd" }, 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 ```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", token: "your-bot-token",
mediaMaxMb: 8, // clamp inbound media size mediaMaxMb: 8, // clamp inbound media size
enableReactions: true, // allow agent-triggered reactions enableReactions: true, // allow agent-triggered reactions
slashCommand: { // user-installed app slash commands
enabled: true,
name: "clawd",
sessionPrefix: "discord:slash",
ephemeral: true
},
dm: { dm: {
enabled: true, // disable all DMs when false enabled: true, // disable all DMs when false
allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names) allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names)
@ -549,7 +555,7 @@ Defaults:
mode: "local", // or "remote" mode: "local", // or "remote"
bind: "loopback", bind: "loopback",
// controlUi: { enabled: true } // 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" } // tailscale: { mode: "off" | "serve" | "funnel" }
} }
} }
@ -560,6 +566,7 @@ Notes:
Auth and Tailscale: Auth and Tailscale:
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`). - `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). - 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.password` can be set here, or via `CLAWDIS_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth. - `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth.

View File

@ -25,8 +25,9 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel. 6. Guild channels: use `channel:<channelId>` 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. 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. 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. 9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists.
10. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool. 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: 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 `#`. 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", token: "abc.123",
mediaMaxMb: 8, mediaMaxMb: 8,
enableReactions: true, enableReactions: true,
slashCommand: {
enabled: true,
name: "clawd",
sessionPrefix: "discord:slash",
ephemeral: true
},
dm: { dm: {
enabled: true, enabled: true,
allowFrom: ["123456789012345678", "steipete"], allowFrom: ["123456789012345678", "steipete"],
@ -77,10 +84,17 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names). - `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids). - `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel). - `guilds.<id>.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. - `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). - `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`). - `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 ## Reactions
When `discord.enableReactions = true`, the agent can call `clawdis_discord` with: When `discord.enableReactions = true`, the agent can call `clawdis_discord` with:
- `action: "react"` - `action: "react"`

36
docs/doctor.md Normal file
View File

@ -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
```

View File

@ -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. 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 ## 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. - **WhatsApp login:** QR code works in terminal — no display needed.
- **Persistence:** Mount `~/.clawdis/` and your workspace as volumes. - **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`. - **Browser automation:** Optional. If needed, install headless Chrome + Playwright deps, or connect to a remote browser via `--remote-debugging-port`.
Basic approach: **Volume mappings (e.g., Unraid):**
```dockerfile
FROM node:22
WORKDIR /app
# Clone, pnpm install, pnpm build
# Mount volumes for persistence
CMD ["pnpm", "clawdis", "gateway"]
``` ```
/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? ### 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. - Use `pm2`, `systemd`, or a `launchd` plist to keep the gateway running.
- Consider Tailscale for secure remote access. - 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 ## 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 ## Troubleshooting
### Build errors (TypeScript) ### Build errors (TypeScript)
@ -246,6 +339,53 @@ Common issues:
- Missing API keys in config - Missing API keys in config
- Invalid config syntax (remember it's JSON5, but still check for errors) - 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 ## Chat Commands

View File

@ -9,7 +9,7 @@ Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that
## Whats implemented (2025-12-03) ## Whats implemented (2025-12-03)
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bots 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. - Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bots 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:<jid>` 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. - Per-group sessions: session keys look like `whatsapp:group:<jid>` 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]`. - 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. - 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 mention`
- `/activation always` - `/activation always`
Only the owner number (from `routing.allowFrom`, defaulting to the bots 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 bots own E.164 when unset) can change this. `/status` in the group shows the current activation mode.
## How to use ## How to use
1) Add Clawd UK (`+447700900123`) to the group. 1) Add Clawd UK (`+447700900123`) to the group.

View File

@ -40,7 +40,7 @@ Group owners can toggle per-group activation:
- `/activation mention` - `/activation mention`
- `/activation always` - `/activation always`
Owner is determined by `routing.allowFrom` (or the bots default identity when unset). Owner is determined by `whatsapp.allowFrom` (or the bots self E.164 when unset). Other surfaces currently ignore `/activation`.
## Context fields ## Context fields
Group inbound payloads set: Group inbound payloads set:

View File

@ -22,7 +22,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing.
## When something fails ## When something fails
- `logged out` or status 409515 → relink with `clawdis logout` then `clawdis login`. - `logged out` or status 409515 → relink with `clawdis logout` then `clawdis login`.
- Gateway unreachable → start it: `clawdis gateway --port 18789` (use `--force` if the port is busy). - 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 ## 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 <ms>` to override the 10s default. `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 <ms>` to override the 10s default.

View File

@ -100,16 +100,14 @@ clawdis send --to +15555550123 --message "Hello from CLAWDIS"
Config lives at `~/.clawdis/clawdis.json`. 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 **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: Example:
```json5 ```json5
{ {
routing: { whatsapp: { allowFrom: ["+15555550123"] },
allowFrom: ["+15555550123"], routing: { groupChat: { requireMention: true, mentionPatterns: ["@clawd"] } }
groupChat: { requireMention: true, mentionPatterns: ["@clawd"] }
}
} }
``` ```

View File

@ -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. - **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**. - **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). 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) ## 2) Local-only: Connect Claude (Anthropic OAuth)

View File

@ -42,7 +42,7 @@ This is social engineering 101. Create distrust, encourage snooping.
```json ```json
{ {
"routing": { "whatsapp": {
"allowFrom": ["+15555550123"] "allowFrom": ["+15555550123"]
} }
} }

View File

@ -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`. - 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). 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:<chatId>` and require mention/command to trigger replies. 5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:<chatId>` 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) ## Capabilities & limits (Bot API)
- Sees only messages sent after its added to a chat; no pre-history access. - Sees only messages sent after its added to a chat; no pre-history access.

View File

@ -22,9 +22,9 @@ The agent was interrupted mid-response.
### Messages Not Triggering ### Messages Not Triggering
**Check 1:** Is the sender in `routing.allowFrom`? **Check 1:** Is the sender in `whatsapp.allowFrom`?
```bash ```bash
cat ~/.clawdis/clawdis.json | jq '.routing.allowFrom' cat ~/.clawdis/clawdis.json | jq '.whatsapp.allowFrom'
``` ```
**Check 2:** For group chats, is mention required? **Check 2:** For group chats, is mention required?

View File

@ -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. - Inbox listeners are detached on shutdown to avoid accumulating event handlers in tests/restarts.
- Status/broadcast chats are ignored. - Status/broadcast chats are ignored.
- Direct chats use E.164; groups use group JID. - Direct chats use E.164; groups use group JID.
- **Allowlist**: `routing.allowFrom` enforced for direct chats only. - **Allowlist**: `whatsapp.allowFrom` enforced for direct chats only.
- If `routing.allowFrom` is empty, default allowlist = self number (self-chat mode). - If `whatsapp.allowFrom` is empty, default allowlist = self number (self-chat mode).
- **Self-chat mode**: avoids auto read receipts and ignores mention JIDs. - **Self-chat mode**: avoids auto read receipts and ignores mention JIDs.
- Read receipts sent for non-self-chat DMs. - 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. - `mention` (default): requires @mention or regex match.
- `always`: always triggers. - `always`: always triggers.
- `/activation mention|always` is owner-only. - `/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**: - **History injection**:
- Recent messages (default 50) inserted under: - Recent messages (default 50) inserted under:
`[Chat messages since your last reply - for context]` `[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. - Logged-out => stop and require re-link.
## Config quick map ## Config quick map
- `routing.allowFrom` (DM allowlist). - `whatsapp.allowFrom` (DM allowlist).
- `routing.groupChat.mentionPatterns` - `routing.groupChat.mentionPatterns`
- `routing.groupChat.historyLimit` - `routing.groupChat.historyLimit`
- `messages.messagePrefix` (inbound prefix) - `messages.messagePrefix` (inbound prefix)

View File

@ -58,6 +58,7 @@ It does **not** install or change anything on the remote host.
4) **Gateway** 4) **Gateway**
- Port, bind, auth mode, tailscale exposure. - 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.
- Nonloopback binds require auth. - Nonloopback binds require auth.
5) **Providers** 5) **Providers**

View File

@ -98,6 +98,8 @@ cat > "$ENT_TMP_BASE" <<'PLIST'
<true/> <true/>
<key>com.apple.security.device.audio-input</key> <key>com.apple.security.device.audio-input</key>
<true/> <true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict> </dict>
</plist> </plist>
PLIST PLIST
@ -111,6 +113,8 @@ cat > "$ENT_TMP_APP_BASE" <<'PLIST'
<true/> <true/>
<key>com.apple.security.device.audio-input</key> <key>com.apple.security.device.audio-input</key>
<true/> <true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict> </dict>
</plist> </plist>
PLIST PLIST
@ -139,6 +143,8 @@ cat > "$ENT_TMP_APP" <<'PLIST'
<true/> <true/>
<key>com.apple.security.device.audio-input</key> <key>com.apple.security.device.audio-input</key>
<true/> <true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict> </dict>
</plist> </plist>
PLIST PLIST

View File

@ -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
```

View File

@ -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.

View File

@ -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"]

View File

@ -0,0 +1,2 @@
__all__ = ["__version__"]
__version__ = "0.1.0"

View File

@ -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)

View File

@ -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)

View File

@ -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

29
skills/songsee/SKILL.md Normal file
View File

@ -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.

49
skills/weather/SKILL.md Normal file
View File

@ -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&current_weather=true"
```
Find coordinates for a city, then query. Returns JSON with temp, windspeed, weathercode.
Docs: https://open-meteo.com/en/docs

View File

@ -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");
});
});

View File

@ -43,7 +43,7 @@ import { parseDurationMs } from "../cli/parse-duration.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { reactMessageDiscord } from "../discord/send.js"; import { reactMessageDiscord } from "../discord/send.js";
import { callGateway } from "../gateway/call.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"; import { sanitizeToolResultImages } from "./tool-images.js";
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-ai uses a different module instance. // 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); await writeBase64ToFile(filePath, payload.base64);
const mimeType = const mimeType =
payload.format === "jpeg" ? "image/jpeg" : "image/png"; imageMimeFromFormat(payload.format) ?? "image/png";
return await imageResult({ return await imageResult({
label: "canvas:snapshot", label: "canvas:snapshot",
path: filePath, path: filePath,
@ -1141,7 +1141,8 @@ function createNodesTool(): AnyAgentTool {
content.push({ content.push({
type: "image", type: "image",
data: payload.base64, data: payload.base64,
mimeType: payload.format === "jpeg" ? "image/jpeg" : "image/png", mimeType:
imageMimeFromFormat(payload.format) ?? "image/png",
}); });
details.push({ details.push({
facing, facing,

View File

@ -10,7 +10,7 @@ describe("buildAgentSystemPromptAppend", () => {
expect(prompt).toContain("## User Identity"); expect(prompt).toContain("## User Identity");
expect(prompt).toContain( 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.",
); );
}); });

View File

@ -25,7 +25,7 @@ export function buildAgentSystemPromptAppend(params: {
.filter(Boolean); .filter(Boolean);
const ownerLine = const ownerLine =
ownerNumbers.length > 0 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; : undefined;
const reasoningHint = params.reasoningTagHint const reasoningHint = params.reasoningTagHint
? [ ? [
@ -36,7 +36,7 @@ export function buildAgentSystemPromptAppend(params: {
"Only text inside <final> is shown to the user; everything else is discarded and never seen by the user.", "Only text inside <final> is shown to the user; everything else is discarded and never seen by the user.",
"Example:", "Example:",
"<think>Short internal reasoning.</think>", "<think>Short internal reasoning.</think>",
"<final>Hey Peter! What would you like to do next?</final>", "<final>Hey there! What would you like to do next?</final>",
].join(" ") ].join(" ")
: undefined; : undefined;
const runtimeInfo = params.runtimeInfo; const runtimeInfo = params.runtimeInfo;

View File

@ -118,7 +118,7 @@ describe("directive parsing", () => {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
}, },
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
@ -168,7 +168,7 @@ describe("directive parsing", () => {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
}, },
routing: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: storePath }, session: { store: storePath },
}, },
); );
@ -195,7 +195,7 @@ describe("directive parsing", () => {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
}, },
routing: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: storePath }, session: { store: storePath },
}, },
); );
@ -208,7 +208,7 @@ describe("directive parsing", () => {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
}, },
routing: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: storePath }, session: { store: storePath },
}, },
); );
@ -264,7 +264,7 @@ describe("directive parsing", () => {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
}, },
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
session: { store: storePath }, session: { store: storePath },
@ -325,7 +325,7 @@ describe("directive parsing", () => {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
}, },
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
session: { store: storePath }, session: { store: storePath },
@ -506,7 +506,7 @@ describe("directive parsing", () => {
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
allowedModels: ["openai/gpt-4.1-mini"], allowedModels: ["openai/gpt-4.1-mini"],
}, },
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
session: { store: storePath }, session: { store: storePath },

View File

@ -42,7 +42,7 @@ function makeCfg(home: string) {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"), workspace: join(home, "clawd"),
}, },
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
session: { store: join(home, "sessions.json") }, session: { store: join(home, "sessions.json") },
@ -283,8 +283,10 @@ describe("trigger handling", () => {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"), workspace: join(home, "clawd"),
}, },
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
},
routing: {
groupChat: { requireMention: false }, groupChat: { requireMention: false },
}, },
session: { store: join(home, "sessions.json") }, session: { store: join(home, "sessions.json") },
@ -324,7 +326,7 @@ describe("trigger handling", () => {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"), workspace: join(home, "clawd"),
}, },
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
session: { session: {
@ -363,7 +365,7 @@ describe("trigger handling", () => {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"), workspace: join(home, "clawd"),
}, },
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
session: { session: {

View File

@ -841,14 +841,27 @@ export async function getReplyFromConfig(
const perMessageQueueMode = const perMessageQueueMode =
hasQueueDirective && !inlineQueueReset ? inlineQueueMode : undefined; hasQueueDirective && !inlineQueueReset ? inlineQueueMode : undefined;
// Optional allowlist by origin number (E.164 without whatsapp: prefix) const surface = (ctx.Surface ?? "").trim().toLowerCase();
const configuredAllowFrom = cfg.routing?.allowFrom; 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 from = (ctx.From ?? "").replace(/^whatsapp:/, "");
const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
const isSamePhone = from && to && from === to; const isEmptyConfig = Object.keys(cfg).length === 0;
// If no config is present, default to self-only DM access. if (isWhatsAppSurface && isEmptyConfig && from && to && from !== to) {
cleanupTyping();
return undefined;
}
const defaultAllowFrom = const defaultAllowFrom =
(!configuredAllowFrom || configuredAllowFrom.length === 0) && to isWhatsAppSurface &&
(!configuredAllowFrom || configuredAllowFrom.length === 0) &&
to
? [to] ? [to]
: undefined; : undefined;
const allowFrom = const allowFrom =
@ -862,10 +875,12 @@ export async function getReplyFromConfig(
: rawBodyNormalized; : rawBodyNormalized;
const activationCommand = parseActivationCommand(commandBodyNormalized); const activationCommand = parseActivationCommand(commandBodyNormalized);
const senderE164 = normalizeE164(ctx.SenderE164 ?? ""); const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
const ownerCandidates = (allowFrom ?? []).filter( const ownerCandidates = isWhatsAppSurface
(entry) => entry && entry !== "*", ? (allowFrom ?? []).filter((entry) => entry && entry !== "*")
); : [];
if (ownerCandidates.length === 0 && to) ownerCandidates.push(to); if (isWhatsAppSurface && ownerCandidates.length === 0 && to) {
ownerCandidates.push(to);
}
const ownerList = ownerCandidates const ownerList = ownerCandidates
.map((entry) => normalizeE164(entry)) .map((entry) => normalizeE164(entry))
.filter((entry): entry is string => Boolean(entry)); .filter((entry): entry is string => Boolean(entry));
@ -876,20 +891,6 @@ export async function getReplyFromConfig(
abortedLastRun = ABORT_MEMORY.get(abortKey) ?? false; 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 || "<unknown>"} not in allowFrom list`,
);
cleanupTyping();
return undefined;
}
}
if (activationCommand.hasCommand) { if (activationCommand.hasCommand) {
if (!isGroup) { if (!isGroup) {
cleanupTyping(); cleanupTyping();

View File

@ -2,6 +2,7 @@ export type MsgContext = {
Body?: string; Body?: string;
From?: string; From?: string;
To?: string; To?: string;
SessionKey?: string;
MessageSid?: string; MessageSid?: string;
ReplyToId?: string; ReplyToId?: string;
ReplyToBody?: string; ReplyToBody?: string;

View File

@ -1 +1 @@
969df6da368b3a802bf0f7f34bf2e30102ae51d91daf45f1fb9328877e2fb335 988ec7bedb11cab74f82faf4475df758e6f07866b69949ffc2cce89cb3d8265b

View File

@ -10,6 +10,7 @@ import { sessionsCommand } from "../commands/sessions.js";
import { setupCommand } from "../commands/setup.js"; import { setupCommand } from "../commands/setup.js";
import { statusCommand } from "../commands/status.js"; import { statusCommand } from "../commands/status.js";
import { updateCommand } from "../commands/update.js"; import { updateCommand } from "../commands/update.js";
import { readConfigFileSnapshot } from "../config/config.js";
import { danger, setVerbose } from "../globals.js"; import { danger, setVerbose } from "../globals.js";
import { loginWeb, logoutWeb } from "../provider-web.js"; import { loginWeb, logoutWeb } from "../provider-web.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
@ -68,6 +69,21 @@ export function buildProgram() {
} }
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`); 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 = [ const examples = [
[ [
"clawdis login --verbose", "clawdis login --verbose",

View File

@ -158,7 +158,7 @@ export async function agentCommand(
}); });
const workspaceDir = workspace.dir; const workspaceDir = workspace.dir;
const allowFrom = (cfg.routing?.allowFrom ?? []) const allowFrom = (cfg.whatsapp?.allowFrom ?? [])
.map((val) => normalizeE164(val)) .map((val) => normalizeE164(val))
.filter((val) => val.length > 1); .filter((val) => val.length > 1);
@ -451,7 +451,7 @@ export async function agentCommand(
if (deliver) { if (deliver) {
if (deliveryProvider === "whatsapp" && !whatsappTarget) { if (deliveryProvider === "whatsapp" && !whatsappTarget) {
const err = new Error( const err = new Error(
"Delivering to WhatsApp requires --to <E.164> or routing.allowFrom[0]", "Delivering to WhatsApp requires --to <E.164> or whatsapp.allowFrom[0]",
); );
if (!bestEffortDeliver) throw err; if (!bestEffortDeliver) throw err;
logDeliveryError(err); logDeliveryError(err);

101
src/commands/doctor.test.ts Normal file
View File

@ -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<string, unknown>,
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<string, unknown>) => 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<string, unknown>)?.allowFrom).toEqual([
"+15555550123",
]);
expect(written.routing).toBeUndefined();
});
});

View File

@ -4,6 +4,7 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import type { ClawdisConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
import { import {
CONFIG_PATH_CLAWDIS, CONFIG_PATH_CLAWDIS,
migrateLegacyConfig,
readConfigFileSnapshot, readConfigFileSnapshot,
writeConfigFile, writeConfigFile,
} from "../config/config.js"; } from "../config/config.js";
@ -29,10 +30,42 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
const snapshot = await readConfigFileSnapshot(); const snapshot = await readConfigFileSnapshot();
let cfg: ClawdisConfig = snapshot.valid ? snapshot.config : {}; 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"); 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( const workspaceDir = resolveUserPath(
cfg.agent?.workspace ?? DEFAULT_WORKSPACE, cfg.agent?.workspace ?? DEFAULT_WORKSPACE,
); );
@ -57,7 +90,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
healthOk = true; healthOk = true;
} catch (err) { } 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) { if (!healthOk) {
@ -79,7 +117,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
try { try {
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
} catch (err) { } 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}`);
}
} }
} }
} }

View File

@ -280,8 +280,16 @@ export async function runInteractiveOnboarding(
await select({ await select({
message: "Gateway auth", message: "Gateway auth",
options: [ 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" }, { value: "password", label: "Password" },
], ],
}), }),
@ -344,6 +352,7 @@ export async function runInteractiveOnboarding(
const tokenInput = guardCancel( const tokenInput = guardCancel(
await text({ await text({
message: "Gateway token (blank to generate)", message: "Gateway token (blank to generate)",
placeholder: "Needed for multi-machine or non-loopback access",
initialValue: randomToken(), initialValue: randomToken(),
}), }),
runtime, runtime,
@ -375,7 +384,11 @@ export async function runInteractiveOnboarding(
...nextConfig, ...nextConfig,
gateway: { gateway: {
...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( note(
(() => { (() => {
const links = resolveControlUiLinks({ bind, port }); const links = resolveControlUiLinks({ bind, port });
return [`Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`].join( const tokenParam =
"\n", 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", "Control UI",
); );
@ -498,7 +520,11 @@ export async function runInteractiveOnboarding(
); );
if (wantsOpen) { if (wantsOpen) {
const links = resolveControlUiLinks({ bind, port }); 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."); outro("Onboarding complete.");

View File

@ -64,11 +64,11 @@ function noteDiscordTokenHelp(): void {
); );
} }
function setRoutingAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) { function setWhatsAppAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) {
return { return {
...cfg, ...cfg,
routing: { whatsapp: {
...cfg.routing, ...cfg.whatsapp,
allowFrom, allowFrom,
}, },
}; };
@ -78,13 +78,13 @@ async function promptWhatsAppAllowFrom(
cfg: ClawdisConfig, cfg: ClawdisConfig,
runtime: RuntimeEnv, runtime: RuntimeEnv,
): Promise<ClawdisConfig> { ): Promise<ClawdisConfig> {
const existingAllowFrom = cfg.routing?.allowFrom ?? []; const existingAllowFrom = cfg.whatsapp?.allowFrom ?? [];
const existingLabel = const existingLabel =
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
note( 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.', 'Default (unset) = self-chat only; use "*" to allow anyone.',
`Current: ${existingLabel}`, `Current: ${existingLabel}`,
].join("\n"), ].join("\n"),
@ -114,8 +114,8 @@ async function promptWhatsAppAllowFrom(
) as (typeof options)[number]["value"]; ) as (typeof options)[number]["value"];
if (mode === "keep") return cfg; if (mode === "keep") return cfg;
if (mode === "self") return setRoutingAllowFrom(cfg, undefined); if (mode === "self") return setWhatsAppAllowFrom(cfg, undefined);
if (mode === "any") return setRoutingAllowFrom(cfg, ["*"]); if (mode === "any") return setWhatsAppAllowFrom(cfg, ["*"]);
const allowRaw = guardCancel( const allowRaw = guardCancel(
await text({ await text({
@ -148,7 +148,7 @@ async function promptWhatsAppAllowFrom(
part === "*" ? "*" : normalizeE164(part), part === "*" ? "*" : normalizeE164(part),
); );
const unique = [...new Set(normalized.filter(Boolean))]; const unique = [...new Set(normalized.filter(Boolean))];
return setRoutingAllowFrom(cfg, unique); return setWhatsAppAllowFrom(cfg, unique);
} }
export async function setupProviders( export async function setupProviders(

View File

@ -22,6 +22,21 @@ function summarizeInstallFailure(message: string): string | undefined {
return cleaned.length > maxLen ? `${cleaned.slice(0, maxLen - 1)}` : cleaned; 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( function upsertSkillEntry(
cfg: ClawdisConfig, cfg: ClawdisConfig,
skillKey: string, skillKey: string,
@ -104,7 +119,7 @@ export async function setupSkills(
...installable.map((skill) => ({ ...installable.map((skill) => ({
value: skill.name, value: skill.name,
label: `${skill.emoji ?? "🧩"} ${skill.name}`, label: `${skill.emoji ?? "🧩"} ${skill.name}`,
hint: skill.install[0]?.label ?? "install", hint: formatSkillHint(skill),
})), })),
], ],
}), }),

View File

@ -488,3 +488,50 @@ describe("talk.voiceAliases", () => {
expect(res.ok).toBe(false); 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");
});
});
});

View File

@ -58,6 +58,11 @@ export type WebConfig = {
reconnect?: WebReconnectConfig; reconnect?: WebReconnectConfig;
}; };
export type WhatsAppConfig = {
/** Optional allowlist for WhatsApp direct chats (E.164). */
allowFrom?: string[];
};
export type BrowserConfig = { export type BrowserConfig = {
enabled?: boolean; enabled?: boolean;
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */ /** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
@ -187,6 +192,17 @@ export type DiscordGuildEntry = {
channels?: Record<string, DiscordGuildChannelConfig>; channels?: Record<string, DiscordGuildChannelConfig>;
}; };
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 = { export type DiscordConfig = {
/** If false, do not start the Discord provider. Default: true. */ /** If false, do not start the Discord provider. Default: true. */
enabled?: boolean; enabled?: boolean;
@ -195,6 +211,7 @@ export type DiscordConfig = {
historyLimit?: number; historyLimit?: number;
/** Allow agent-triggered Discord reactions (default: true). */ /** Allow agent-triggered Discord reactions (default: true). */
enableReactions?: boolean; enableReactions?: boolean;
slashCommand?: DiscordSlashCommandConfig;
dm?: DiscordDmConfig; dm?: DiscordDmConfig;
/** New per-guild config keyed by guild id or slug. */ /** New per-guild config keyed by guild id or slug. */
guilds?: Record<string, DiscordGuildEntry>; guilds?: Record<string, DiscordGuildEntry>;
@ -260,7 +277,6 @@ export type GroupChatConfig = {
}; };
export type RoutingConfig = { export type RoutingConfig = {
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
transcribeAudio?: { transcribeAudio?: {
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
command: string[]; command: string[];
@ -335,6 +351,8 @@ export type GatewayAuthMode = "token" | "password";
export type GatewayAuthConfig = { export type GatewayAuthConfig = {
/** Authentication mode for Gateway connections. Defaults to token when set. */ /** Authentication mode for Gateway connections. Defaults to token when set. */
mode?: GatewayAuthMode; mode?: GatewayAuthMode;
/** Shared token for token mode (stored locally for CLI auth). */
token?: string;
/** Shared password for password mode (consider env instead). */ /** Shared password for password mode (consider env instead). */
password?: string; password?: string;
/** Allow Tailscale identity headers when serve mode is enabled. */ /** Allow Tailscale identity headers when serve mode is enabled. */
@ -525,6 +543,7 @@ export type ClawdisConfig = {
messages?: MessagesConfig; messages?: MessagesConfig;
session?: SessionConfig; session?: SessionConfig;
web?: WebConfig; web?: WebConfig;
whatsapp?: WhatsAppConfig;
telegram?: TelegramConfig; telegram?: TelegramConfig;
discord?: DiscordConfig; discord?: DiscordConfig;
signal?: SignalConfig; signal?: SignalConfig;
@ -693,7 +712,6 @@ const HeartbeatSchema = z
const RoutingSchema = z const RoutingSchema = z
.object({ .object({
allowFrom: z.array(z.string()).optional(),
groupChat: GroupChatSchema, groupChat: GroupChatSchema,
transcribeAudio: TranscribeAudioSchema, transcribeAudio: TranscribeAudioSchema,
queue: z queue: z
@ -909,6 +927,11 @@ const ClawdisSchema = z.object({
.optional(), .optional(),
}) })
.optional(), .optional(),
whatsapp: z
.object({
allowFrom: z.array(z.string()).optional(),
})
.optional(),
telegram: z telegram: z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
@ -927,6 +950,14 @@ const ClawdisSchema = z.object({
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
token: z.string().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(), mediaMaxMb: z.number().positive().optional(),
historyLimit: z.number().int().min(0).optional(), historyLimit: z.number().int().min(0).optional(),
enableReactions: z.boolean().optional(), enableReactions: z.boolean().optional(),
@ -1068,6 +1099,7 @@ const ClawdisSchema = z.object({
auth: z auth: z
.object({ .object({
mode: z.union([z.literal("token"), z.literal("password")]).optional(), mode: z.union([z.literal("token"), z.literal("password")]).optional(),
token: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
allowTailscale: z.boolean().optional(), allowTailscale: z.boolean().optional(),
}) })
@ -1131,6 +1163,11 @@ export type ConfigValidationIssue = {
message: string; message: string;
}; };
export type LegacyConfigIssue = {
path: string;
message: string;
};
export type ConfigFileSnapshot = { export type ConfigFileSnapshot = {
path: string; path: string;
exists: boolean; exists: boolean;
@ -1139,8 +1176,102 @@ export type ConfigFileSnapshot = {
valid: boolean; valid: boolean;
config: ClawdisConfig; config: ClawdisConfig;
issues: ConfigValidationIssue[]; issues: ConfigValidationIssue[];
legacyIssues: LegacyConfigIssue[];
}; };
type LegacyConfigRule = {
path: string[];
message: string;
};
type LegacyConfigMigration = {
id: string;
describe: string;
apply: (raw: Record<string, unknown>, 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<string, unknown>).allowFrom;
if (allowFrom === undefined) return;
const whatsapp =
raw.whatsapp && typeof raw.whatsapp === "object"
? (raw.whatsapp as Record<string, unknown>)
: {};
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<string, unknown>).allowFrom;
if (Object.keys(routing as Record<string, unknown>).length === 0) {
delete raw.routing;
}
raw.whatsapp = whatsapp;
},
},
];
function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
if (!raw || typeof raw !== "object") return [];
const root = raw as Record<string, unknown>;
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<string, unknown>)[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<string, unknown>;
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 { function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
@ -1199,6 +1330,16 @@ export function validateConfigObject(
): ):
| { ok: true; config: ClawdisConfig } | { ok: true; config: ClawdisConfig }
| { ok: false; issues: ConfigValidationIssue[] } { | { 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); const validated = ClawdisSchema.safeParse(raw);
if (!validated.success) { if (!validated.success) {
return { return {
@ -1271,6 +1412,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
const exists = fs.existsSync(configPath); const exists = fs.existsSync(configPath);
if (!exists) { if (!exists) {
const config = applyTalkApiKey({}); const config = applyTalkApiKey({});
const legacyIssues: LegacyConfigIssue[] = [];
return { return {
path: configPath, path: configPath,
exists: false, exists: false,
@ -1279,6 +1421,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
valid: true, valid: true,
config, config,
issues: [], issues: [],
legacyIssues,
}; };
} }
@ -1296,9 +1439,12 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
issues: [ issues: [
{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }, { path: "", message: `JSON5 parse failed: ${parsedRes.error}` },
], ],
legacyIssues: [],
}; };
} }
const legacyIssues = findLegacyConfigIssues(parsedRes.parsed);
const validated = validateConfigObject(parsedRes.parsed); const validated = validateConfigObject(parsedRes.parsed);
if (!validated.ok) { if (!validated.ok) {
return { return {
@ -1309,6 +1455,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
valid: false, valid: false,
config: {}, config: {},
issues: validated.issues, issues: validated.issues,
legacyIssues,
}; };
} }
@ -1320,6 +1467,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
valid: true, valid: true,
config: applyTalkApiKey(validated.config), config: applyTalkApiKey(validated.config),
issues: [], issues: [],
legacyIssues,
}; };
} catch (err) { } catch (err) {
return { return {
@ -1330,6 +1478,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
valid: false, valid: false,
config: {}, config: {},
issues: [{ path: "", message: `read failed: ${String(err)}` }], issues: [{ path: "", message: `read failed: ${String(err)}` }],
legacyIssues: [],
}; };
} }
} }

View File

@ -349,6 +349,8 @@ export function resolveSessionKey(
ctx: MsgContext, ctx: MsgContext,
mainKey?: string, mainKey?: string,
) { ) {
const explicit = ctx.SessionKey?.trim();
if (explicit) return explicit;
const raw = deriveSessionKey(scope, ctx); const raw = deriveSessionKey(scope, ctx);
if (scope === "global") return raw; if (scope === "global") return raw;
// Default to a single shared direct-chat session called "main"; groups stay isolated. // Default to a single shared direct-chat session called "main"; groups stay isolated.

View File

@ -103,7 +103,7 @@ function resolveDeliveryTarget(
const sanitizedWhatsappTo = (() => { const sanitizedWhatsappTo = (() => {
if (channel !== "whatsapp") return to; if (channel !== "whatsapp") return to;
const rawAllow = cfg.routing?.allowFrom ?? []; const rawAllow = cfg.whatsapp?.allowFrom ?? [];
if (rawAllow.includes("*")) return to; if (rawAllow.includes("*")) return to;
const allowFrom = rawAllow const allowFrom = rawAllow
.map((val) => normalizeE164(val)) .map((val) => normalizeE164(val))

View File

@ -1,6 +1,8 @@
import { import {
ApplicationCommandOptionType,
ChannelType, ChannelType,
Client, Client,
type CommandInteractionOption,
Events, Events,
GatewayIntentBits, GatewayIntentBits,
type Message, type Message,
@ -10,10 +12,12 @@ import {
import { chunkText } from "../auto-reply/chunk.js"; import { chunkText } from "../auto-reply/chunk.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { getReplyFromConfig } from "../auto-reply/reply.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 { ReplyPayload } from "../auto-reply/types.js";
import type { DiscordSlashCommandConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.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 { getChildLogger } from "../logging.js";
import { detectMime } from "../media/mime.js"; import { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js"; import { saveMediaBuffer } from "../media/store.js";
@ -25,6 +29,7 @@ export type MonitorDiscordOpts = {
token?: string; token?: string;
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
slashCommand?: DiscordSlashCommandConfig;
mediaMaxMb?: number; mediaMaxMb?: number;
historyLimit?: number; historyLimit?: number;
}; };
@ -86,6 +91,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const dmConfig = cfg.discord?.dm; const dmConfig = cfg.discord?.dm;
const guildEntries = cfg.discord?.guilds; const guildEntries = cfg.discord?.guilds;
const allowFrom = dmConfig?.allowFrom; const allowFrom = dmConfig?.allowFrom;
const slashCommand = resolveSlashCommandConfig(
opts.slashCommand ?? cfg.discord?.slashCommand,
);
const mediaMaxBytes = const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
const historyLimit = Math.max( const historyLimit = Math.max(
@ -111,6 +119,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
client.once(Events.ClientReady, () => { client.once(Events.ClientReady, () => {
runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`); runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`);
if (slashCommand.enabled) {
void ensureSlashCommand(client, slashCommand, runtime);
}
}); });
client.on(Events.Error, (err) => { 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 client.login(token);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@ -614,6 +782,86 @@ export function resolveGroupDmAllow(params: {
}); });
} }
async function ensureSlashCommand(
client: Client,
slashCommand: Required<DiscordSlashCommandConfig>,
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<DiscordSlashCommandConfig> {
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) { async function sendTyping(message: Message) {
try { try {
const channel = message.channel; const channel = message.channel;
@ -659,3 +907,45 @@ async function deliverReplies({
runtime.log?.(`delivered reply to ${target}`); 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 });
}
}

View File

@ -101,7 +101,7 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void { export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
if (auth.mode === "token" && !auth.token) { if (auth.mode === "token" && !auth.token) {
throw new Error( 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) { if (auth.mode === "password" && !auth.password) {

View File

@ -25,8 +25,8 @@ export async function callGateway<T = unknown>(
): Promise<T> { ): Promise<T> {
const timeoutMs = opts.timeoutMs ?? 10_000; const timeoutMs = opts.timeoutMs ?? 10_000;
const config = loadConfig(); const config = loadConfig();
const remote = const isRemoteMode = config.gateway?.mode === "remote";
config.gateway?.mode === "remote" ? config.gateway.remote : undefined; const remote = isRemoteMode ? config.gateway.remote : undefined;
const url = const url =
(typeof opts.url === "string" && opts.url.trim().length > 0 (typeof opts.url === "string" && opts.url.trim().length > 0
? opts.url.trim() ? opts.url.trim()
@ -39,14 +39,20 @@ export async function callGateway<T = unknown>(
(typeof opts.token === "string" && opts.token.trim().length > 0 (typeof opts.token === "string" && opts.token.trim().length > 0
? opts.token.trim() ? opts.token.trim()
: undefined) || : undefined) ||
(typeof remote?.token === "string" && remote.token.trim().length > 0 (isRemoteMode
? remote.token.trim() ? typeof remote?.token === "string" && remote.token.trim().length > 0
: undefined); ? 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 = const password =
(typeof opts.password === "string" && opts.password.trim().length > 0 (typeof opts.password === "string" && opts.password.trim().length > 0
? opts.password.trim() ? opts.password.trim()
: undefined) || : undefined) ||
process.env.CLAWDIS_GATEWAY_PASSWORD || process.env.CLAWDIS_GATEWAY_PASSWORD?.trim() ||
(typeof remote?.password === "string" && remote.password.trim().length > 0 (typeof remote?.password === "string" && remote.password.trim().length > 0
? remote.password.trim() ? remote.password.trim()
: undefined); : undefined);

View File

@ -130,6 +130,11 @@ let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
let testGatewayAuth: Record<string, unknown> | undefined; let testGatewayAuth: Record<string, unknown> | undefined;
let testHooksConfig: Record<string, unknown> | undefined; let testHooksConfig: Record<string, unknown> | undefined;
let testCanvasHostPort: number | undefined; let testCanvasHostPort: number | undefined;
let testLegacyIssues: Array<{ path: string; message: string }> = [];
let testLegacyParsed: Record<string, unknown> = {};
let testMigrationConfig: Record<string, unknown> | null = null;
let testMigrationChanges: string[] = [];
const testIsNixMode = vi.hoisted(() => ({ value: false }));
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 })); const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
vi.mock("../config/sessions.js", async () => { vi.mock("../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../config/sessions.js")>( const actual = await vi.importActual<typeof import("../config/sessions.js")>(
@ -151,6 +156,21 @@ vi.mock("../config/config.js", () => {
path.join(os.homedir(), ".clawdis", "clawdis.json"); path.join(os.homedir(), ".clawdis", "clawdis.json");
const readConfigFileSnapshot = async () => { 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(); const configPath = resolveConfigPath();
try { try {
await fs.access(configPath); await fs.access(configPath);
@ -163,6 +183,7 @@ vi.mock("../config/config.js", () => {
valid: true, valid: true,
config: {}, config: {},
issues: [], issues: [],
legacyIssues: [],
}; };
} }
try { try {
@ -176,6 +197,7 @@ vi.mock("../config/config.js", () => {
valid: true, valid: true,
config: parsed, config: parsed,
issues: [], issues: [],
legacyIssues: [],
}; };
} catch (err) { } catch (err) {
return { return {
@ -186,27 +208,34 @@ vi.mock("../config/config.js", () => {
valid: false, valid: false,
config: {}, config: {},
issues: [{ path: "", message: `read failed: ${String(err)}` }], issues: [{ path: "", message: `read failed: ${String(err)}` }],
legacyIssues: [],
}; };
} }
}; };
const writeConfigFile = async (cfg: Record<string, unknown>) => { const writeConfigFile = vi.fn(async (cfg: Record<string, unknown>) => {
const configPath = resolveConfigPath(); const configPath = resolveConfigPath();
await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.mkdir(path.dirname(configPath), { recursive: true });
const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n"); const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n");
await fs.writeFile(configPath, raw, "utf-8"); await fs.writeFile(configPath, raw, "utf-8");
}; });
return { return {
CONFIG_PATH_CLAWDIS: resolveConfigPath(), CONFIG_PATH_CLAWDIS: resolveConfigPath(),
STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()), STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()),
isNixMode: false, get isNixMode() {
return testIsNixMode.value;
},
migrateLegacyConfig: (raw: unknown) => ({
config: testMigrationConfig ?? (raw as Record<string, unknown>),
changes: testMigrationChanges,
}),
loadConfig: () => ({ loadConfig: () => ({
agent: { agent: {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-5",
workspace: path.join(os.tmpdir(), "clawd-gateway-test"), workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
}, },
routing: { whatsapp: {
allowFrom: testAllowFrom, allowFrom: testAllowFrom,
}, },
session: { mainKey: "main", store: testSessionStorePath }, session: { mainKey: "main", store: testSessionStorePath },
@ -279,6 +308,11 @@ beforeEach(async () => {
testGatewayAuth = undefined; testGatewayAuth = undefined;
testHooksConfig = undefined; testHooksConfig = undefined;
testCanvasHostPort = undefined; testCanvasHostPort = undefined;
testLegacyIssues = [];
testLegacyParsed = {};
testMigrationConfig = null;
testMigrationChanges = [];
testIsNixMode.value = false;
cronIsolatedRun.mockClear(); cronIsolatedRun.mockClear();
drainSystemEvents(); drainSystemEvents();
resetAgentRunContextForTest(); 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 () => { test("models.list returns model catalog", async () => {
piSdkMock.enabled = true; piSdkMock.enabled = true;
piSdkMock.models = [ piSdkMock.models = [

View File

@ -48,6 +48,7 @@ import {
CONFIG_PATH_CLAWDIS, CONFIG_PATH_CLAWDIS,
isNixMode, isNixMode,
loadConfig, loadConfig,
migrateLegacyConfig,
parseConfigJson5, parseConfigJson5,
readConfigFileSnapshot, readConfigFileSnapshot,
STATE_DIR_CLAWDIS, STATE_DIR_CLAWDIS,
@ -659,8 +660,6 @@ type DedupeEntry = {
error?: ErrorShape; error?: ErrorShape;
}; };
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
function formatForLog(value: unknown): string { function formatForLog(value: unknown): string {
try { try {
if (value instanceof Error) { if (value instanceof Error) {
@ -1322,6 +1321,31 @@ export async function startGatewayServer(
port = 18789, port = 18789,
opts: GatewayServerOptions = {}, opts: GatewayServerOptions = {},
): Promise<GatewayServer> { ): Promise<GatewayServer> {
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 cfgAtStart = loadConfig();
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback"; const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode); const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
@ -1345,7 +1369,8 @@ export async function startGatewayServer(
...tailscaleOverrides, ...tailscaleOverrides,
}; };
const tailscaleMode = tailscaleConfig.mode ?? "off"; const tailscaleMode = tailscaleConfig.mode ?? "off";
const token = getGatewayToken(); const token =
authConfig.token ?? process.env.CLAWDIS_GATEWAY_TOKEN ?? undefined;
const password = const password =
authConfig.password ?? process.env.CLAWDIS_GATEWAY_PASSWORD ?? undefined; authConfig.password ?? process.env.CLAWDIS_GATEWAY_PASSWORD ?? undefined;
const authMode: ResolvedGatewayAuth["mode"] = const authMode: ResolvedGatewayAuth["mode"] =
@ -2228,6 +2253,7 @@ export async function startGatewayServer(
token: discordToken.trim(), token: discordToken.trim(),
runtime: discordRuntimeEnv, runtime: discordRuntimeEnv,
abortSignal: discordAbort.signal, abortSignal: discordAbort.signal,
slashCommand: cfg.discord?.slashCommand,
mediaMaxMb: cfg.discord?.mediaMaxMb, mediaMaxMb: cfg.discord?.mediaMaxMb,
historyLimit: cfg.discord?.historyLimit, historyLimit: cfg.discord?.historyLimit,
}) })
@ -6641,7 +6667,7 @@ export async function startGatewayServer(
if (explicit) return resolvedTo; if (explicit) return resolvedTo;
const cfg = cfgForAgent ?? loadConfig(); const cfg = cfgForAgent ?? loadConfig();
const rawAllow = cfg.routing?.allowFrom ?? []; const rawAllow = cfg.whatsapp?.allowFrom ?? [];
if (rawAllow.includes("*")) return resolvedTo; if (rawAllow.includes("*")) return resolvedTo;
const allowFrom = rawAllow const allowFrom = rawAllow
.map((val) => normalizeE164(val)) .map((val) => normalizeE164(val))

View File

@ -61,8 +61,7 @@ function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv {
function resolveAllowFrom(opts: MonitorIMessageOpts): string[] { function resolveAllowFrom(opts: MonitorIMessageOpts): string[] {
const cfg = loadConfig(); const cfg = loadConfig();
const raw = const raw = opts.allowFrom ?? cfg.imessage?.allowFrom ?? [];
opts.allowFrom ?? cfg.imessage?.allowFrom ?? cfg.routing?.allowFrom ?? [];
return raw.map((entry) => String(entry).trim()).filter(Boolean); return raw.map((entry) => String(entry).trim()).filter(Boolean);
} }

View File

@ -94,7 +94,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
it("applies allowFrom fallback for WhatsApp targets", () => { it("applies allowFrom fallback for WhatsApp targets", () => {
const cfg: ClawdisConfig = { const cfg: ClawdisConfig = {
agent: { heartbeat: { target: "whatsapp", to: "+1999" } }, agent: { heartbeat: { target: "whatsapp", to: "+1999" } },
routing: { allowFrom: ["+1555", "+1666"] }, whatsapp: { allowFrom: ["+1555", "+1666"] },
}; };
const entry = { const entry = {
...baseEntry, ...baseEntry,
@ -145,7 +145,7 @@ describe("runHeartbeatOnce", () => {
agent: { agent: {
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
}, },
routing: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: storePath }, session: { store: storePath },
}; };
@ -206,7 +206,7 @@ describe("runHeartbeatOnce", () => {
agent: { agent: {
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
}, },
routing: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: storePath }, session: { store: storePath },
}; };

View File

@ -235,7 +235,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
return { channel, to }; return { channel, to };
} }
const rawAllow = cfg.routing?.allowFrom ?? []; const rawAllow = cfg.whatsapp?.allowFrom ?? [];
if (rawAllow.includes("*")) return { channel, to }; if (rawAllow.includes("*")) return { channel, to };
const allowFrom = rawAllow const allowFrom = rawAllow
.map((val) => normalizeE164(val)) .map((val) => normalizeE164(val))
@ -401,7 +401,7 @@ export async function runHeartbeatOnce(opts: {
const startedAt = opts.deps?.nowMs?.() ?? Date.now(); const startedAt = opts.deps?.nowMs?.() ?? Date.now();
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg); const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg);
const previousUpdatedAt = entry?.updatedAt; const previousUpdatedAt = entry?.updatedAt;
const allowFrom = cfg.routing?.allowFrom ?? []; const allowFrom = cfg.whatsapp?.allowFrom ?? [];
const sender = resolveHeartbeatSender({ const sender = resolveHeartbeatSender({
allowFrom, allowFrom,
lastTo: entry?.lastTo, lastTo: entry?.lastTo,

View File

@ -80,8 +80,8 @@ export async function buildProviderSummary(
); );
} }
const allowFrom = effective.routing?.allowFrom?.length const allowFrom = effective.whatsapp?.allowFrom?.length
? effective.routing.allowFrom.map(normalizeE164).filter(Boolean) ? effective.whatsapp.allowFrom.map(normalizeE164).filter(Boolean)
: []; : [];
if (allowFrom.length) { if (allowFrom.length) {
lines.push(chalk.cyan(`AllowFrom: ${allowFrom.join(", ")}`)); lines.push(chalk.cyan(`AllowFrom: ${allowFrom.join(", ")}`));

View File

@ -1,7 +1,7 @@
import JSZip from "jszip"; import JSZip from "jszip";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { detectMime } from "./mime.js"; import { detectMime, imageMimeFromFormat } from "./mime.js";
async function makeOoxmlZip(opts: { async function makeOoxmlZip(opts: {
mainMime: string; mainMime: string;
@ -17,6 +17,15 @@ async function makeOoxmlZip(opts: {
} }
describe("mime detection", () => { 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 () => { it("detects docx from buffer", async () => {
const buf = await makeOoxmlZip({ const buf = await makeOoxmlZip({
mainMime: mainMime:

View File

@ -107,6 +107,25 @@ export function extensionForMime(mime?: string | null): string | undefined {
return EXT_BY_MIME[mime.toLowerCase()]; 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 { export function kindFromMime(mime?: string | null): MediaKind {
return mediaKindFromMime(mime); return mediaKindFromMime(mime);
} }

View File

@ -92,8 +92,7 @@ function resolveAccount(opts: MonitorSignalOpts): string | undefined {
function resolveAllowFrom(opts: MonitorSignalOpts): string[] { function resolveAllowFrom(opts: MonitorSignalOpts): string[] {
const cfg = loadConfig(); const cfg = loadConfig();
const raw = const raw = opts.allowFrom ?? cfg.signal?.allowFrom ?? [];
opts.allowFrom ?? cfg.signal?.allowFrom ?? cfg.routing?.allowFrom ?? [];
return raw.map((entry) => String(entry).trim()).filter(Boolean); return raw.map((entry) => String(entry).trim()).filter(Boolean);
} }

View File

@ -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, * "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). * "bot" and the human are the same WhatsApp identity (e.g. auto read receipts, @mention JID triggers).
*/ */
export function isSelfChatMode( export function isSelfChatMode(

View File

@ -111,7 +111,7 @@ describe("partial reply gating", () => {
const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" }); const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" });
const mockConfig: ClawdisConfig = { const mockConfig: ClawdisConfig = {
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
}; };
@ -158,7 +158,7 @@ describe("partial reply gating", () => {
const replyResolver = vi.fn().mockResolvedValue(undefined); const replyResolver = vi.fn().mockResolvedValue(undefined);
const mockConfig: ClawdisConfig = { const mockConfig: ClawdisConfig = {
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
session: { store: store.storePath, mainKey: "main" }, session: { store: store.storePath, mainKey: "main" },
@ -1097,9 +1097,11 @@ describe("web auto-reply", () => {
const resolver = vi.fn().mockResolvedValue({ text: "ok" }); const resolver = vi.fn().mockResolvedValue({ text: "ok" });
setLoadConfigMock(() => ({ setLoadConfigMock(() => ({
routing: { whatsapp: {
// Self-chat heuristic: allowFrom includes selfE164. // Self-chat heuristic: allowFrom includes selfE164.
allowFrom: ["+999"], allowFrom: ["+999"],
},
routing: {
groupChat: { groupChat: {
requireMention: true, requireMention: true,
mentionPatterns: ["\\bclawd\\b"], mentionPatterns: ["\\bclawd\\b"],
@ -1247,7 +1249,7 @@ describe("web auto-reply", () => {
it("prefixes body with same-phone marker when from === to", async () => { it("prefixes body with same-phone marker when from === to", async () => {
// Enable messagePrefix for same-phone mode testing // Enable messagePrefix for same-phone mode testing
setLoadConfigMock(() => ({ setLoadConfigMock(() => ({
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
messages: { messages: {
@ -1372,7 +1374,7 @@ describe("web auto-reply", () => {
it("applies responsePrefix to regular replies", async () => { it("applies responsePrefix to regular replies", async () => {
setLoadConfigMock(() => ({ setLoadConfigMock(() => ({
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
messages: { messages: {
@ -1417,7 +1419,7 @@ describe("web auto-reply", () => {
it("does not deliver HEARTBEAT_OK responses", async () => { it("does not deliver HEARTBEAT_OK responses", async () => {
setLoadConfigMock(() => ({ setLoadConfigMock(() => ({
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
messages: { messages: {
@ -1462,7 +1464,7 @@ describe("web auto-reply", () => {
it("does not double-prefix if responsePrefix already present", async () => { it("does not double-prefix if responsePrefix already present", async () => {
setLoadConfigMock(() => ({ setLoadConfigMock(() => ({
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
messages: { messages: {
@ -1508,7 +1510,7 @@ describe("web auto-reply", () => {
it("sends tool summaries immediately with responsePrefix", async () => { it("sends tool summaries immediately with responsePrefix", async () => {
setLoadConfigMock(() => ({ setLoadConfigMock(() => ({
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
messages: { messages: {

View File

@ -116,7 +116,7 @@ function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
} }
}) })
.filter((r): r is RegExp => Boolean(r)) ?? []; .filter((r): r is RegExp => Boolean(r)) ?? [];
return { mentionRegexes, allowFrom: cfg.routing?.allowFrom }; return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom };
} }
function isBotMentioned( function isBotMentioned(
@ -448,8 +448,8 @@ export function resolveHeartbeatRecipients(
const sessionRecipients = getSessionRecipients(cfg); const sessionRecipients = getSessionRecipients(cfg);
const allowFrom = const allowFrom =
Array.isArray(cfg.routing?.allowFrom) && cfg.routing.allowFrom.length > 0 Array.isArray(cfg.whatsapp?.allowFrom) && cfg.whatsapp.allowFrom.length > 0
? cfg.routing.allowFrom.filter((v) => v !== "*").map(normalizeE164) ? cfg.whatsapp.allowFrom.filter((v) => v !== "*").map(normalizeE164)
: []; : [];
const unique = (list: string[]) => [...new Set(list.filter(Boolean))]; 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 // Build message prefix: explicit config > default based on allowFrom
let messagePrefix = cfg.messages?.messagePrefix; let messagePrefix = cfg.messages?.messagePrefix;
if (messagePrefix === undefined) { if (messagePrefix === undefined) {
const hasAllowFrom = (cfg.routing?.allowFrom?.length ?? 0) > 0; const hasAllowFrom = (cfg.whatsapp?.allowFrom?.length ?? 0) > 0;
messagePrefix = hasAllowFrom ? "" : "[clawdis]"; messagePrefix = hasAllowFrom ? "" : "[clawdis]";
} }
const prefixStr = messagePrefix ? `${messagePrefix} ` : ""; const prefixStr = messagePrefix ? `${messagePrefix} ` : "";

View File

@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
vi.mock("../config/config.js", () => ({ vi.mock("../config/config.js", () => ({
loadConfig: vi.fn().mockReturnValue({ loadConfig: vi.fn().mockReturnValue({
routing: { whatsapp: {
allowFrom: ["*"], // Allow all in tests allowFrom: ["*"], // Allow all in tests
}, },
messages: { messages: {

View File

@ -157,7 +157,7 @@ export async function monitorWebInbox(options: {
// Filter unauthorized senders early to prevent wasted processing // Filter unauthorized senders early to prevent wasted processing
// and potential session corruption from Bad MAC errors // and potential session corruption from Bad MAC errors
const cfg = loadConfig(); 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 // Without user config, default to self-only DM access so the owner can talk to themselves
const defaultAllowFrom = const defaultAllowFrom =
(!configuredAllowFrom || configuredAllowFrom.length === 0) && selfE164 (!configuredAllowFrom || configuredAllowFrom.length === 0) && selfE164

View File

@ -10,7 +10,7 @@ vi.mock("../media/store.js", () => ({
})); }));
const mockLoadConfig = vi.fn().mockReturnValue({ const mockLoadConfig = vi.fn().mockReturnValue({
routing: { whatsapp: {
allowFrom: ["*"], // Allow all in tests by default allowFrom: ["*"], // Allow all in tests by default
}, },
messages: { messages: {
@ -450,7 +450,7 @@ describe("web monitor inbox", () => {
it("still forwards group messages (with sender info) even when allowFrom is restrictive", async () => { it("still forwards group messages (with sender info) even when allowFrom is restrictive", async () => {
mockLoadConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
routing: { whatsapp: {
allowFrom: ["+111"], // does not include +777 allowFrom: ["+111"], // does not include +777
}, },
messages: { messages: {
@ -506,7 +506,7 @@ describe("web monitor inbox", () => {
// Test for auto-recovery fix: early allowFrom filtering prevents Bad MAC errors // Test for auto-recovery fix: early allowFrom filtering prevents Bad MAC errors
// from unauthorized senders corrupting sessions // from unauthorized senders corrupting sessions
mockLoadConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
routing: { whatsapp: {
allowFrom: ["+111"], // Only allow +111 allowFrom: ["+111"], // Only allow +111
}, },
messages: { messages: {
@ -546,7 +546,7 @@ describe("web monitor inbox", () => {
// Reset mock for other tests // Reset mock for other tests
mockLoadConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
messages: { messages: {
@ -561,7 +561,7 @@ describe("web monitor inbox", () => {
it("skips read receipts in self-chat mode", async () => { it("skips read receipts in self-chat mode", async () => {
mockLoadConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
routing: { whatsapp: {
// Self-chat heuristic: allowFrom includes selfE164 (+123). // Self-chat heuristic: allowFrom includes selfE164 (+123).
allowFrom: ["+123"], allowFrom: ["+123"],
}, },
@ -598,7 +598,7 @@ describe("web monitor inbox", () => {
// Reset mock for other tests // Reset mock for other tests
mockLoadConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
messages: { messages: {
@ -613,7 +613,7 @@ describe("web monitor inbox", () => {
it("lets group messages through even when sender not in allowFrom", async () => { it("lets group messages through even when sender not in allowFrom", async () => {
mockLoadConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
routing: { whatsapp: {
allowFrom: ["+1234"], allowFrom: ["+1234"],
}, },
messages: { messages: {
@ -655,7 +655,7 @@ describe("web monitor inbox", () => {
it("allows messages from senders in allowFrom list", async () => { it("allows messages from senders in allowFrom list", async () => {
mockLoadConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
routing: { whatsapp: {
allowFrom: ["+111", "+999"], // Allow +999 allowFrom: ["+111", "+999"], // Allow +999
}, },
messages: { messages: {
@ -690,7 +690,7 @@ describe("web monitor inbox", () => {
// Reset mock for other tests // Reset mock for other tests
mockLoadConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
messages: { messages: {
@ -707,7 +707,7 @@ describe("web monitor inbox", () => {
// Same-phone mode: when from === selfJid, should always be allowed // Same-phone mode: when from === selfJid, should always be allowed
// This allows users to message themselves even with restrictive allowFrom // This allows users to message themselves even with restrictive allowFrom
mockLoadConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
routing: { whatsapp: {
allowFrom: ["+111"], // Only allow +111, but self is +123 allowFrom: ["+111"], // Only allow +111, but self is +123
}, },
messages: { messages: {
@ -810,7 +810,7 @@ it("defaults to self-only when no config is present", async () => {
// Reset mock for other tests // Reset mock for other tests
mockLoadConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
routing: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
}, },
messages: { messages: {

View File

@ -6,7 +6,7 @@ import { createMockBaileys } from "../../test/mocks/baileys.js";
// Use globalThis to store the mock config so it survives vi.mock hoisting // Use globalThis to store the mock config so it survives vi.mock hoisting
const CONFIG_KEY = Symbol.for("clawdis:testConfigMock"); const CONFIG_KEY = Symbol.for("clawdis:testConfigMock");
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
routing: { whatsapp: {
// Tests can override; default remains open to avoid surprising fixtures // Tests can override; default remains open to avoid surprising fixtures
allowFrom: ["*"], allowFrom: ["*"],
}, },

View File

@ -186,6 +186,7 @@ export class ClawdisApp extends LitElement {
this.syncThemeWithSettings(); this.syncThemeWithSettings();
this.attachThemeListener(); this.attachThemeListener();
window.addEventListener("popstate", this.popStateHandler); window.addEventListener("popstate", this.popStateHandler);
this.applySettingsFromUrl();
this.connect(); this.connect();
this.startNodesPolling(); 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) { setTab(next: Tab) {
if (this.tab !== next) this.tab = next; if (this.tab !== next) this.tab = next;
void this.refreshActiveTab(); void this.refreshActiveTab();

View File

@ -90,4 +90,13 @@ describe("control UI routing", () => {
expect(maxScroll).toBeGreaterThan(0); expect(maxScroll).toBeGreaterThan(0);
expect(container.scrollTop).toBe(maxScroll); 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("");
});
}); });