feat: add remote clawd toggle

This commit is contained in:
Peter Steinberger
2025-12-07 18:19:37 +01:00
parent c06f49cb3e
commit 2a45455c80
8 changed files with 314 additions and 44 deletions

View File

@@ -5,6 +5,11 @@ import SwiftUI
@MainActor
final class AppState: ObservableObject {
enum ConnectionMode: String {
case local
case remote
}
@Published var isPaused: Bool {
didSet { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) }
}
@@ -105,6 +110,22 @@ final class AppState: ObservableObject {
}
}
@Published var connectionMode: ConnectionMode {
didSet { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
}
@Published var remoteTarget: String {
didSet { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
}
@Published var remoteIdentity: String {
didSet { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) }
}
@Published var remoteProjectRoot: String {
didSet { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) }
}
private var earBoostTask: Task<Void, Never>?
init() {
@@ -133,8 +154,14 @@ final class AppState: ObservableObject {
self.voiceWakeForwardTarget = UserDefaults.standard
.string(forKey: voiceWakeForwardTargetKey) ?? legacyTarget
self.voiceWakeForwardIdentity = UserDefaults.standard.string(forKey: voiceWakeForwardIdentityKey) ?? ""
self.voiceWakeForwardCommand = UserDefaults.standard
var storedForwardCommand = UserDefaults.standard
.string(forKey: voiceWakeForwardCommandKey) ?? defaultVoiceWakeForwardCommand
if !storedForwardCommand.contains("--deliver") || !storedForwardCommand.contains("--session") {
storedForwardCommand = defaultVoiceWakeForwardCommand
UserDefaults.standard.set(storedForwardCommand, forKey: voiceWakeForwardCommandKey)
}
self.voiceWakeForwardCommand = storedForwardCommand
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
self.heartbeatsEnabled = storedHeartbeats
} else {
@@ -142,6 +169,12 @@ final class AppState: ObservableObject {
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
}
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
self.connectionMode = ConnectionMode(rawValue: storedMode ?? "local") ?? .local
self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
self.swabbleEnabled = false
}

View File

@@ -3,7 +3,7 @@ import Foundation
let serviceName = "com.steipete.clawdis.xpc"
let launchdLabel = "com.steipete.clawdis"
let onboardingVersionKey = "clawdis.onboardingVersion"
let currentOnboardingVersion = 2
let currentOnboardingVersion = 3
let pauseDefaultsKey = "clawdis.pauseEnabled"
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
let swabbleEnabledKey = "clawdis.swabbleEnabled"
@@ -20,6 +20,10 @@ let voiceWakeForwardUserKey = "clawdis.voiceWakeForwardUser"
let voiceWakeForwardPortKey = "clawdis.voiceWakeForwardPort"
let voiceWakeForwardIdentityKey = "clawdis.voiceWakeForwardIdentity"
let voiceWakeForwardCommandKey = "clawdis.voiceWakeForwardCommand"
let connectionModeKey = "clawdis.connectionMode"
let remoteTargetKey = "clawdis.remoteTarget"
let remoteIdentityKey = "clawdis.remoteIdentity"
let remoteProjectRootKey = "clawdis.remoteProjectRoot"
let modelCatalogPathKey = "clawdis.modelCatalogPath"
let modelCatalogReloadKey = "clawdis.modelCatalogReload"
let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled"

View File

@@ -8,9 +8,12 @@ struct GeneralSettings: View {
@State private var cliStatus: String?
@State private var cliInstalled = false
@State private var cliInstallLocation: String?
@State private var remoteStatus: RemoteStatus = .idle
var body: some View {
VStack(alignment: .leading, spacing: 18) {
self.connectionSection
if !self.state.onboardingSeen {
Text("Complete onboarding to finish setup")
.font(.callout.weight(.semibold))
@@ -87,6 +90,80 @@ struct GeneralSettings: View {
set: { self.state.isPaused = !$0 })
}
private var connectionSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Clawdis runs")
.font(.callout.weight(.semibold))
Picker("Mode", selection: self.$state.connectionMode) {
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
}
.pickerStyle(.segmented)
.frame(width: 320)
if self.state.connectionMode == .remote {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("SSH target") {
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: 260)
}
LabeledContent("Identity file") {
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: 260)
}
LabeledContent("Project root") {
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: 320)
}
HStack(spacing: 10) {
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.disabled(self.remoteStatus == .checking || self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
switch self.remoteStatus {
case .idle:
EmptyView()
case .checking:
Text("Checking…").font(.caption).foregroundStyle(.secondary)
case .ok:
Label("Ready", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
case let .failed(message):
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
Text("Tip: use Tailscale for stable remote access; we recommend enabling it when you pick a remote Clawdis.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.background(Color.gray.opacity(0.08))
.cornerRadius(10)
.transition(.opacity)
}
}
}
private var cliInstaller: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
@@ -217,7 +294,27 @@ struct GeneralSettings: View {
}
}
private enum RemoteStatus: Equatable {
case idle
case checking
case ok
case failed(String)
}
extension GeneralSettings {
@MainActor
fileprivate func testRemote() async {
self.remoteStatus = .checking
let command = CommandResolver.clawdisCommand(subcommand: "status", extraArgs: ["--json"])
let response = await ShellRunner.run(command: command, cwd: nil, env: nil, timeout: 10)
if response.ok {
self.remoteStatus = .ok
} else {
let msg = response.message ?? "test failed"
self.remoteStatus = .failed(msg)
}
}
private func revealLogs() {
let path = URL(fileURLWithPath: "/tmp/clawdis/clawdis.log")
if FileManager.default.fileExists(atPath: path.path) {

View File

@@ -64,10 +64,10 @@ struct OnboardingView: View {
GeometryReader { _ in
HStack(spacing: 0) {
self.welcomePage().frame(width: self.pageWidth)
self.focusPage().frame(width: self.pageWidth)
self.connectionPage().frame(width: self.pageWidth)
self.permissionsPage().frame(width: self.pageWidth)
self.cliPage().frame(width: self.pageWidth)
self.launchPage().frame(width: self.pageWidth)
self.whatsappPage().frame(width: self.pageWidth)
self.readyPage().frame(width: self.pageWidth)
}
.offset(x: CGFloat(-self.currentPage) * self.pageWidth)
@@ -113,25 +113,48 @@ struct OnboardingView: View {
}
}
private func focusPage() -> some View {
private func connectionPage() -> some View {
self.onboardingPage {
Text("What Clawdis handles")
Text("Where Clawdis runs")
.font(.largeTitle.weight(.semibold))
Text("Pick local or remote. Remote uses SSH; we recommend Tailscale for reliable reachability.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard {
self.featureRow(
title: "Owns the TCC prompts",
subtitle: "Requests Notifications, Accessibility, and Screen Recording "
+ "so your agents stay unblocked.",
systemImage: "lock.shield")
self.featureRow(
title: "Native notifications",
subtitle: "Shows desktop toasts for agent events with your preferred sound.",
systemImage: "bell.and.waveform")
self.featureRow(
title: "Privileged helpers",
subtitle: "Runs screenshots or shell actions from the `clawdis-mac` CLI "
+ "with the right permissions.",
systemImage: "terminal")
Picker("Mode", selection: self.$state.connectionMode) {
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
}
.pickerStyle(.segmented)
.frame(width: 320)
if self.state.connectionMode == .remote {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("SSH target") {
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("Identity file") {
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("Project root") {
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: 320)
}
Text("Tip: keep a Tailscale IP here so the agent stays reachable off-LAN.")
.font(.footnote)
.foregroundStyle(.secondary)
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
}
@@ -212,11 +235,11 @@ struct OnboardingView: View {
}
}
private func launchPage() -> some View {
private func whatsappPage() -> some View {
self.onboardingPage {
Text("Keep it running")
Text("Link WhatsApp")
.font(.largeTitle.weight(.semibold))
Text("Let Clawdis launch with macOS so permissions and notifications are ready when automations start.")
Text("Run `clawdis login` where the relay runs (local if local mode, remote if remote). Scan the QR to pair your account.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -224,21 +247,18 @@ struct OnboardingView: View {
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard {
HStack {
Spacer()
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
.toggleStyle(.switch)
.onChange(of: self.state.launchAtLogin) { _, newValue in
AppStateStore.updateLaunchAtLogin(enabled: newValue)
}
Spacer()
}
Text(
"You can pause from the menu bar anytime. Settings keeps a \"Show onboarding\" "
+ "button if you need to revisit.")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
self.featureRow(
title: "Open a terminal",
subtitle: "Use the same host selected above. If remote, SSH in first.",
systemImage: "terminal")
self.featureRow(
title: "Run `clawdis login --verbose`",
subtitle: "Scan the QR code with WhatsApp on your phone. We only use your personal session; no cloud relay involved.",
systemImage: "qrcode.viewfinder")
self.featureRow(
title: "Re-link after timeouts",
subtitle: "If Baileys auth expires, re-run login on that host. Settings → General shows remote/local mode so you know where to run it.",
systemImage: "clock.arrow.circlepath")
}
}
}
@@ -257,6 +277,10 @@ struct OnboardingView: View {
title: "Test a notification",
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",
systemImage: "bell.badge")
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
.onChange(of: self.state.launchAtLogin) { _, newValue in
AppStateStore.updateLaunchAtLogin(enabled: newValue)
}
}
Text("Finish to save this version of onboarding. We'll reshow automatically when steps change.")
.font(.footnote)

View File

@@ -178,7 +178,26 @@ enum CommandResolver {
private static let projectRootDefaultsKey = "clawdis.relayProjectRootPath"
private static let helperName = "clawdis"
private static func bundledRelayRoot() -> URL? {
guard let resource = Bundle.main.resourceURL else { return nil }
let relay = resource.appendingPathComponent("Relay")
return FileManager.default.fileExists(atPath: relay.path) ? relay : nil
}
private static func bundledRelayCommand(subcommand: String, extraArgs: [String]) -> [String]? {
guard let relay = self.bundledRelayRoot() else { return nil }
let bunPath = relay.appendingPathComponent("bun").path
let entry = relay.appendingPathComponent("dist/index.js").path
guard FileManager.default.isExecutableFile(atPath: bunPath),
FileManager.default.isReadableFile(atPath: entry)
else { return nil }
return [bunPath, entry, subcommand] + extraArgs
}
static func projectRoot() -> URL {
if let bundled = self.bundledRelayRoot() {
return bundled
}
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
let url = self.expandPath(stored)
{
@@ -203,7 +222,7 @@ enum CommandResolver {
static func preferredPaths() -> [String] {
let current = ProcessInfo.processInfo.environment["PATH"]?
.split(separator: ":").map(String.init) ?? []
let extras = [
var extras = [
self.projectRoot().appendingPathComponent("node_modules/.bin").path,
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/pnpm").path,
"/opt/homebrew/bin",
@@ -211,6 +230,9 @@ enum CommandResolver {
"/usr/bin",
"/bin",
]
if let relay = self.bundledRelayRoot() {
extras.insert(relay.appendingPathComponent("node_modules/.bin").path, at: 0)
}
var seen = Set<String>()
return (extras + current).filter { seen.insert($0).inserted }
}
@@ -242,6 +264,13 @@ enum CommandResolver {
}
static func clawdisCommand(subcommand: String, extraArgs: [String] = []) -> [String] {
let settings = self.connectionSettings()
if settings.mode == .remote, let ssh = self.sshCommand(subcommand: subcommand, extraArgs: extraArgs, settings: settings) {
return ssh
}
if let bundled = self.bundledRelayCommand(subcommand: subcommand, extraArgs: extraArgs) {
return bundled
}
if let clawdisPath = self.clawdisExecutable() {
return [clawdisPath, subcommand] + extraArgs
}
@@ -257,6 +286,53 @@ enum CommandResolver {
return ["clawdis", subcommand] + extraArgs
}
private static func sshCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? {
guard !settings.target.isEmpty else { return nil }
let parsed = VoiceWakeForwarder.parse(target: settings.target)
guard let parsed else { return nil }
var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
args.append(contentsOf: ["-i", settings.identity])
}
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
args.append(userHost)
let quotedArgs = (["clawdis", subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
let cdPrefix = settings.projectRoot.isEmpty ? "" : "cd \(self.shellQuote(settings.projectRoot)) && "
let scriptBody = "\(cdPrefix)\(quotedArgs)"
let wrapped = VoiceWakeForwarder.commandWithCliPath(scriptBody, target: settings.target)
args.append(contentsOf: ["/bin/sh", "-c", wrapped])
return ["/usr/bin/ssh"] + args
}
private struct RemoteSettings {
let mode: AppState.ConnectionMode
let target: String
let identity: String
let projectRoot: String
}
private static func connectionSettings() -> RemoteSettings {
let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey) ?? "local"
let mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
let target = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
let identity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
let projectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
return RemoteSettings(mode: mode, target: self.sanitizedTarget(target), identity: identity, projectRoot: projectRoot)
}
private static func sanitizedTarget(_ raw: String) -> String {
VoiceWakeForwarder.sanitizedTarget(raw)
}
private static func shellQuote(_ text: String) -> String {
if text.isEmpty { return "''" }
let escaped = text.replacingOccurrences(of: "'", with: "'\\''")
return "'\(escaped)'"
}
private static func expandPath(_ path: String) -> URL? {
var expanded = path
if expanded.hasPrefix("~") {

View File

@@ -138,7 +138,9 @@ enum VoiceWakeForwarder {
}
args.append(userHost)
let escaped = Self.shellEscape(transcript) // single-quoted literal, safe for sh/zsh
// Avoid stdin and globbing entirely: marshal the transcript as a single-quoted literal.
// `shellEscape` keeps it POSIX-safe for /bin/sh even when the text has quotes/parentheses.
let escaped = Self.shellEscape(transcript)
let templated: String = config.commandTemplate.contains("${text}")
? config.commandTemplate.replacingOccurrences(of: "${text}", with: "$CLAW_TEXT")
: Self.renderedCommand(template: config.commandTemplate, transcript: transcript)
@@ -320,7 +322,7 @@ enum VoiceWakeForwarder {
return (user: user?.trimmingCharacters(in: .whitespacesAndNewlines), host: host, port: port)
}
private static func sanitizedTarget(_ raw: String) -> String {
static func sanitizedTarget(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("ssh ") {
return trimmed.replacingOccurrences(of: "ssh ", with: "").trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -215,10 +215,15 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
let data: Data
do {
data = try await Task.detached(priority: .utility) { () -> Data in
let command = CommandResolver.clawdisCommand(
subcommand: "agent",
extraArgs: ["--to", sessionKey, "--message", text, "--json"])
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["pnpm", "clawdis", "agent", "--to", sessionKey, "--message", text, "--json"]
process.currentDirectoryURL = URL(fileURLWithPath: "/Users/steipete/Projects/clawdis")
process.executableURL = URL(fileURLWithPath: command.first ?? "/usr/bin/env")
process.arguments = Array(command.dropFirst())
if command.first != "/usr/bin/ssh" {
process.currentDirectoryURL = URL(fileURLWithPath: CommandResolver.projectRootPath())
}
let pipe = Pipe()
process.standardOutput = pipe

29
docs/mac/remote.md Normal file
View File

@@ -0,0 +1,29 @@
# Remote Clawd mode (Dec 2025)
## What it is
- Run the Clawdis relay on another machine (Linux/macOS) reachable over SSH while the macOS app keeps TCC, notifications, and UI.
- You can toggle Local vs Remote in **Settings → General → Clawdis runs**; remote adds fields for SSH target, identity file, and project root.
- We recommend running a Tailscale node on both sides so the target is reachable even off-LAN.
## Requirements
- SSH access with public-key auth (`BatchMode=yes`); set `user@host[:port]` and an identity file.
- The remote host must have a working `clawdis` install in the project root you specify.
- `clawdis-mac` is still used for permissioned actions; the CLI path is auto-discovered on the remote via `command -v` + common prefixes.
## How it works
- The app builds commands through the new runner:
- `clawdis status/health/agent/relay` are wrapped in `ssh … /bin/sh -c '<cd project && clawdis …>'` with CLI path lookup.
- `clawdis rpc` is tunneled over a long-lived SSH process so web chat and the apps Agent tab stay responsive.
- Local TCC flows remain unchanged; if the remote agent needs local permissions, it should SSH back here and invoke `clawdis-mac …` (same CLI surface).
## Setup steps
1) Open **Settings → General → Clawdis runs** and pick **Remote over SSH**.
2) Fill **SSH target**, **Identity file**, and **Project root** (where `clawdis` lives on the remote).
3) Click **Test remote**; it runs `clawdis status --json` remotely and caches the resolved CLI path.
4) Run onboardings WhatsApp login step on the machine where the relay will run (remote if remote mode is enabled).
## Notes
- Connection strings accept `user@host:port`; leading `ssh ` is stripped if pasted from a shell snippet.
- Project root defaults to the path you enter; if blank, no `cd` is issued before the relay command.
- The remote log path remains `/tmp/clawdis/clawdis.log`; view it via SSH if you need details.
- If you switch back to Local, existing remote state is left untouched; re-run Test remote when switching again.