diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 01f6ef8ff..ada67dfdb 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -1,5 +1,5 @@ // swift-tools-version: 6.2 -// Package manifest for the Clawdis macOS companion (menu bar app + CLI + IPC library). +// Package manifest for the Clawdis macOS companion (menu bar app + IPC library). import PackageDescription @@ -11,7 +11,6 @@ let package = Package( products: [ .library(name: "ClawdisIPC", targets: ["ClawdisIPC"]), .executable(name: "Clawdis", targets: ["Clawdis"]), - .executable(name: "ClawdisCLI", targets: ["ClawdisCLI"]), ], dependencies: [ .package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"), @@ -55,15 +54,6 @@ let package = Package( swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), - .executableTarget( - name: "ClawdisCLI", - dependencies: [ - "ClawdisIPC", - "ClawdisProtocol", - ], - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), .testTarget( name: "ClawdisIPCTests", dependencies: ["ClawdisIPC", "Clawdis", "ClawdisProtocol"], @@ -71,11 +61,4 @@ let package = Package( .enableUpcomingFeature("StrictConcurrency"), .enableExperimentalFeature("SwiftTesting"), ]), - .testTarget( - name: "ClawdisCLITests", - dependencies: ["ClawdisCLI"], - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - .enableExperimentalFeature("SwiftTesting"), - ]), ]) diff --git a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift deleted file mode 100644 index 30a989cde..000000000 --- a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift +++ /dev/null @@ -1,614 +0,0 @@ -import ClawdisIPC -import ClawdisKit -import Foundation -import OSLog - -enum ControlRequestHandler { - struct NodeListNode: Codable { - var nodeId: String - var displayName: String? - var platform: String? - var version: String? - var deviceFamily: String? - var modelIdentifier: String? - var remoteAddress: String? - var connected: Bool - var paired: Bool - var capabilities: [String]? - var commands: [String]? - } - - struct NodeListResult: Codable { - var ts: Int - var connectedNodeIds: [String] - var pairedNodeIds: [String] - var nodes: [NodeListNode] - } - - struct GatewayNodeListPayload: Decodable { - struct Node: Decodable { - var nodeId: String - var displayName: String? - var platform: String? - var version: String? - var deviceFamily: String? - var modelIdentifier: String? - var remoteIp: String? - var connected: Bool? - var paired: Bool? - var caps: [String]? - var commands: [String]? - } - - var ts: Int? - var nodes: [Node] - } - - static func process( - request: Request, - notifier: NotificationManager = NotificationManager(), - logger: Logger = Logger(subsystem: "com.steipete.clawdis", category: "control")) async throws -> Response - { - // Keep `status` responsive even if the main actor is busy. - let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) - if paused, case .status = request { - // allow status through - } else if paused { - return Response(ok: false, message: "clawdis paused") - } - - switch request { - case let .notify(title, body, sound, priority, delivery): - let notify = NotifyRequest( - title: title, - body: body, - sound: sound, - priority: priority, - delivery: delivery) - return await self.handleNotify(notify, notifier: notifier) - - case let .ensurePermissions(caps, interactive): - return await self.handleEnsurePermissions(caps: caps, interactive: interactive) - - case .status: - return paused - ? Response(ok: false, message: "clawdis paused") - : Response(ok: true, message: "ready") - - case .rpcStatus: - return await self.handleRPCStatus() - - case let .runShell(command, cwd, env, timeoutSec, needsSR): - return await self.handleRunShell( - command: command, - cwd: cwd, - env: env, - timeoutSec: timeoutSec, - needsSR: needsSR) - - case let .agent(message, thinking, session, deliver, to): - return await self.handleAgent( - message: message, - thinking: thinking, - session: session, - deliver: deliver, - to: to) - - case let .canvasPresent(session, path, placement): - return await self.handleCanvasPresent(session: session, path: path, placement: placement) - - case let .canvasHide(session): - return await self.handleCanvasHide(session: session) - - case let .canvasEval(session, javaScript): - return await self.handleCanvasEval(session: session, javaScript: javaScript) - - case let .canvasSnapshot(session, outPath): - return await self.handleCanvasSnapshot(session: session, outPath: outPath) - - case let .canvasA2UI(session, command, jsonl): - return await self.handleCanvasA2UI(session: session, command: command, jsonl: jsonl) - - case .nodeList: - return await self.handleNodeList() - - case let .nodeDescribe(nodeId): - return await self.handleNodeDescribe(nodeId: nodeId) - - case let .nodeInvoke(nodeId, command, paramsJSON): - return await self.handleNodeInvoke( - nodeId: nodeId, - command: command, - paramsJSON: paramsJSON, - logger: logger) - - case let .cameraSnap(facing, maxWidth, quality, outPath): - return await self.handleCameraSnap(facing: facing, maxWidth: maxWidth, quality: quality, outPath: outPath) - - case let .cameraClip(facing, durationMs, includeAudio, outPath): - return await self.handleCameraClip( - facing: facing, - durationMs: durationMs, - includeAudio: includeAudio, - outPath: outPath) - - case let .screenRecord(screenIndex, durationMs, fps, includeAudio, outPath): - return await self.handleScreenRecord( - screenIndex: screenIndex, - durationMs: durationMs, - fps: fps, - includeAudio: includeAudio, - outPath: outPath) - } - } - - private struct NotifyRequest { - var title: String - var body: String - var sound: String? - var priority: NotificationPriority? - var delivery: NotificationDelivery? - } - - private static func handleNotify(_ request: NotifyRequest, notifier: NotificationManager) async -> Response { - let chosenSound = request.sound?.trimmingCharacters(in: .whitespacesAndNewlines) - let chosenDelivery = request.delivery ?? .system - - switch chosenDelivery { - case .system: - let ok = await notifier.send( - title: request.title, - body: request.body, - sound: chosenSound, - priority: request.priority) - return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized") - case .overlay: - await NotifyOverlayController.shared.present(title: request.title, body: request.body) - return Response(ok: true) - case .auto: - let ok = await notifier.send( - title: request.title, - body: request.body, - sound: chosenSound, - priority: request.priority) - if ok { return Response(ok: true) } - await NotifyOverlayController.shared.present(title: request.title, body: request.body) - return Response(ok: true, message: "notification not authorized; used overlay") - } - } - - private static func handleEnsurePermissions(caps: [Capability], interactive: Bool) async -> Response { - let statuses = await PermissionManager.ensure(caps, interactive: interactive) - let missing = statuses.filter { !$0.value }.map(\.key.rawValue) - let ok = missing.isEmpty - let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))" - return Response(ok: ok, message: msg) - } - - private static func handleRPCStatus() async -> Response { - let result = await GatewayConnection.shared.status() - return Response(ok: result.ok, message: result.error) - } - - private static func handleRunShell( - command: [String], - cwd: String?, - env: [String: String]?, - timeoutSec: Double?, - needsSR: Bool) async -> Response - { - if needsSR { - let authorized = await PermissionManager - .ensure([.screenRecording], interactive: false)[.screenRecording] ?? false - guard authorized else { return Response(ok: false, message: "screen recording permission missing") } - } - return await ShellExecutor.run(command: command, cwd: cwd, env: env, timeout: timeoutSec) - } - - private static func handleAgent( - message: String, - thinking: String?, - session: String?, - deliver: Bool, - to: String?) async -> Response - { - let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") } - let sessionKey = session ?? "main" - let invocation = GatewayAgentInvocation( - message: trimmed, - sessionKey: sessionKey, - thinking: thinking, - deliver: deliver, - to: to, - channel: .last) - let rpcResult = await GatewayConnection.shared.sendAgent(invocation) - return rpcResult.ok ? Response(ok: true, message: "sent") : Response(ok: false, message: rpcResult.error) - } - - private static func canvasEnabled() -> Bool { - UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true - } - - private static func cameraEnabled() -> Bool { - UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false - } - - private static func handleCanvasPresent( - session: String, - path: String?, - placement: CanvasPlacement?) async -> Response - { - guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") } - _ = session - do { - var params: [String: Any] = [:] - if let path, !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - params["url"] = path - } - if let placement { - var placementPayload: [String: Any] = [:] - if let x = placement.x { placementPayload["x"] = x } - if let y = placement.y { placementPayload["y"] = y } - if let width = placement.width { placementPayload["width"] = width } - if let height = placement.height { placementPayload["height"] = height } - if !placementPayload.isEmpty { - params["placement"] = placementPayload - } - } - _ = try await self.invokeLocalNode( - command: ClawdisCanvasCommand.present.rawValue, - params: params.isEmpty ? nil : params, - timeoutMs: 20000) - return Response(ok: true) - } catch { - return Response(ok: false, message: error.localizedDescription) - } - } - - private static func handleCanvasHide(session: String) async -> Response { - _ = session - do { - _ = try await self.invokeLocalNode( - command: ClawdisCanvasCommand.hide.rawValue, - params: nil, - timeoutMs: 10000) - return Response(ok: true) - } catch { - return Response(ok: false, message: error.localizedDescription) - } - } - - private static func handleCanvasEval(session: String, javaScript: String) async -> Response { - guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") } - _ = session - do { - let payload = try await self.invokeLocalNode( - command: ClawdisCanvasCommand.evalJS.rawValue, - params: ["javaScript": javaScript], - timeoutMs: 20000) - if let dict = payload as? [String: Any], - let result = dict["result"] as? String - { - return Response(ok: true, payload: Data(result.utf8)) - } - return Response(ok: true) - } catch { - return Response(ok: false, message: error.localizedDescription) - } - } - - private static func handleCanvasSnapshot(session: String, outPath: String?) async -> Response { - guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") } - _ = session - do { - let payload = try await self.invokeLocalNode( - command: ClawdisCanvasCommand.snapshot.rawValue, - params: [:], - timeoutMs: 20000) - guard let dict = payload as? [String: Any], - let format = dict["format"] as? String, - let base64 = dict["base64"] as? String, - let data = Data(base64Encoded: base64) - else { - return Response(ok: false, message: "invalid canvas snapshot payload") - } - let ext = (format.lowercased() == "jpeg" || format.lowercased() == "jpg") ? "jpg" : "png" - let url: URL = if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - URL(fileURLWithPath: outPath) - } else { - FileManager.default.temporaryDirectory - .appendingPathComponent("clawdis-canvas-snapshot-\(UUID().uuidString).\(ext)") - } - try data.write(to: url, options: [.atomic]) - return Response(ok: true, message: url.path) - } catch { - return Response(ok: false, message: error.localizedDescription) - } - } - - private static func handleCanvasA2UI( - session: String, - command: CanvasA2UICommand, - jsonl: String?) async -> Response - { - guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") } - _ = session - do { - switch command { - case .reset: - let payload = try await self.invokeLocalNode( - command: ClawdisCanvasA2UICommand.reset.rawValue, - params: nil, - timeoutMs: 20000) - if let payload { - let data = try JSONSerialization.data(withJSONObject: payload) - return Response(ok: true, payload: data) - } - return Response(ok: true) - case .pushJSONL: - guard let jsonl, !jsonl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return Response(ok: false, message: "missing jsonl") - } - let payload = try await self.invokeLocalNode( - command: ClawdisCanvasA2UICommand.pushJSONL.rawValue, - params: ["jsonl": jsonl], - timeoutMs: 30000) - if let payload { - let data = try JSONSerialization.data(withJSONObject: payload) - return Response(ok: true, payload: data) - } - return Response(ok: true) - } - } catch { - return Response(ok: false, message: error.localizedDescription) - } - } - - private static func handleNodeList() async -> Response { - do { - let data = try await GatewayConnection.shared.request( - method: "node.list", - params: [:], - timeoutMs: 10000) - let payload = try JSONDecoder().decode(GatewayNodeListPayload.self, from: data) - let result = self.buildNodeListResult(payload: payload) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let json = (try? encoder.encode(result)) - .flatMap { String(data: $0, encoding: .utf8) } ?? "{}" - return Response(ok: true, payload: Data(json.utf8)) - } catch { - return Response(ok: false, message: error.localizedDescription) - } - } - - private static func handleNodeDescribe(nodeId: String) async -> Response { - do { - let data = try await GatewayConnection.shared.request( - method: "node.describe", - params: ["nodeId": AnyCodable(nodeId)], - timeoutMs: 10000) - return Response(ok: true, payload: data) - } catch { - return Response(ok: false, message: error.localizedDescription) - } - } - - static func buildNodeListResult(payload: GatewayNodeListPayload) -> NodeListResult { - let nodes = payload.nodes.map { n -> NodeListNode in - NodeListNode( - nodeId: n.nodeId, - displayName: n.displayName, - platform: n.platform, - version: n.version, - deviceFamily: n.deviceFamily, - modelIdentifier: n.modelIdentifier, - remoteAddress: n.remoteIp, - connected: n.connected == true, - paired: n.paired == true, - capabilities: n.caps, - commands: n.commands) - } - - let sorted = nodes.sorted { a, b in - (a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId) - } - - let pairedNodeIds = sorted.filter(\.paired).map(\.nodeId).sorted() - let connectedNodeIds = sorted.filter(\.connected).map(\.nodeId).sorted() - - return NodeListResult( - ts: payload.ts ?? Int(Date().timeIntervalSince1970 * 1000), - connectedNodeIds: connectedNodeIds, - pairedNodeIds: pairedNodeIds, - nodes: sorted) - } - - private static func handleNodeInvoke( - nodeId: String, - command: String, - paramsJSON: String?, - logger: Logger) async -> Response - { - do { - var paramsObj: Any? - let raw = (paramsJSON ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if !raw.isEmpty { - if let data = raw.data(using: .utf8) { - paramsObj = try JSONSerialization.jsonObject(with: data) - } else { - return Response(ok: false, message: "params-json not UTF-8") - } - } - - var params: [String: AnyCodable] = [ - "nodeId": AnyCodable(nodeId), - "command": AnyCodable(command), - "idempotencyKey": AnyCodable(UUID().uuidString), - ] - if let paramsObj { - params["params"] = AnyCodable(paramsObj) - } - - let data = try await GatewayConnection.shared.request( - method: "node.invoke", - params: params, - timeoutMs: 30000) - return Response(ok: true, payload: data) - } catch { - logger.error("node invoke failed: \(error.localizedDescription, privacy: .public)") - return Response(ok: false, message: error.localizedDescription) - } - } - - private static func handleCameraSnap( - facing: CameraFacing?, - maxWidth: Int?, - quality: Double?, - outPath: String?) async -> Response - { - guard self.cameraEnabled() else { return Response(ok: false, message: "Camera disabled by user") } - do { - var params: [String: Any] = [:] - if let facing { params["facing"] = facing.rawValue } - if let maxWidth { params["maxWidth"] = maxWidth } - if let quality { params["quality"] = quality } - params["format"] = "jpg" - - let payload = try await self.invokeLocalNode( - command: ClawdisCameraCommand.snap.rawValue, - params: params, - timeoutMs: 30000) - guard let dict = payload as? [String: Any], - let format = dict["format"] as? String, - let base64 = dict["base64"] as? String, - let data = Data(base64Encoded: base64) - else { - return Response(ok: false, message: "invalid camera snapshot payload") - } - - let ext = (format.lowercased() == "jpeg" || format.lowercased() == "jpg") ? "jpg" : format.lowercased() - let url: URL = if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - URL(fileURLWithPath: outPath) - } else { - FileManager.default.temporaryDirectory - .appendingPathComponent("clawdis-camera-snap-\(UUID().uuidString).\(ext)") - } - - try data.write(to: url, options: [.atomic]) - return Response(ok: true, message: url.path) - } catch { - return Response(ok: false, message: error.localizedDescription) - } - } - - private static func handleCameraClip( - facing: CameraFacing?, - durationMs: Int?, - includeAudio: Bool, - outPath: String?) async -> Response - { - guard self.cameraEnabled() else { return Response(ok: false, message: "Camera disabled by user") } - do { - var params: [String: Any] = ["includeAudio": includeAudio, "format": "mp4"] - if let facing { params["facing"] = facing.rawValue } - if let durationMs { params["durationMs"] = durationMs } - - let payload = try await self.invokeLocalNode( - command: ClawdisCameraCommand.clip.rawValue, - params: params, - timeoutMs: 90000) - guard let dict = payload as? [String: Any], - let format = dict["format"] as? String, - let base64 = dict["base64"] as? String, - let data = Data(base64Encoded: base64) - else { - return Response(ok: false, message: "invalid camera clip payload") - } - - let ext = format.lowercased() - let url: URL = if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - URL(fileURLWithPath: outPath) - } else { - FileManager.default.temporaryDirectory - .appendingPathComponent("clawdis-camera-clip-\(UUID().uuidString).\(ext)") - } - try data.write(to: url, options: [.atomic]) - return Response(ok: true, message: url.path) - } catch { - return Response(ok: false, message: error.localizedDescription) - } - } - - private static func handleScreenRecord( - screenIndex: Int?, - durationMs: Int?, - fps: Double?, - includeAudio: Bool, - outPath: String?) async -> Response - { - do { - var params: [String: Any] = ["format": "mp4", "includeAudio": includeAudio] - if let screenIndex { params["screenIndex"] = screenIndex } - if let durationMs { params["durationMs"] = durationMs } - if let fps { params["fps"] = fps } - - let payload = try await self.invokeLocalNode( - command: "screen.record", - params: params, - timeoutMs: 120_000) - guard let dict = payload as? [String: Any], - let base64 = dict["base64"] as? String, - let data = Data(base64Encoded: base64) - else { - return Response(ok: false, message: "invalid screen record payload") - } - let url: URL = if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - URL(fileURLWithPath: outPath) - } else { - FileManager.default.temporaryDirectory - .appendingPathComponent("clawdis-screen-record-\(UUID().uuidString).mp4") - } - try data.write(to: url, options: [.atomic]) - return Response(ok: true, message: url.path) - } catch { - return Response(ok: false, message: error.localizedDescription) - } - } - - private static func invokeLocalNode( - command: String, - params: [String: Any]?, - timeoutMs: Double) async throws -> Any? - { - var gatewayParams: [String: AnyCodable] = [ - "nodeId": AnyCodable(Self.localNodeId()), - "command": AnyCodable(command), - "idempotencyKey": AnyCodable(UUID().uuidString), - ] - if let params { - gatewayParams["params"] = AnyCodable(params) - } - let data = try await GatewayConnection.shared.request( - method: "node.invoke", - params: gatewayParams, - timeoutMs: timeoutMs) - return try Self.decodeNodeInvokePayload(data: data) - } - - private static func decodeNodeInvokePayload(data: Data) throws -> Any? { - let obj = try JSONSerialization.jsonObject(with: data) - guard let dict = obj as? [String: Any] else { - throw NSError(domain: "Node", code: 30, userInfo: [ - NSLocalizedDescriptionKey: "invalid node invoke response", - ]) - } - return dict["payload"] - } - - private static func localNodeId() -> String { - "mac-\(InstanceIdentity.instanceId)" - } -} diff --git a/apps/macos/Sources/Clawdis/ControlSocketServer.swift b/apps/macos/Sources/Clawdis/ControlSocketServer.swift deleted file mode 100644 index 4f6bcac8b..000000000 --- a/apps/macos/Sources/Clawdis/ControlSocketServer.swift +++ /dev/null @@ -1,311 +0,0 @@ -import ClawdisIPC -import Darwin -import Foundation -import OSLog - -/// Lightweight UNIX-domain socket server so `clawdis-mac` can talk to the app -/// without a launchd MachService. Listens on `controlSocketPath`. -final actor ControlSocketServer { - private nonisolated static let logger = Logger(subsystem: "com.steipete.clawdis", category: "control.socket") - - private var listenFD: Int32 = -1 - private var acceptTask: Task? - - private let socketPath: String - private let maxRequestBytes: Int - private let allowedTeamIDs: Set - private let requestTimeoutSec: TimeInterval - - init( - socketPath: String = controlSocketPath, - maxRequestBytes: Int = 512 * 1024, - allowedTeamIDs: Set = ["Y5PE65HELJ"], - requestTimeoutSec: TimeInterval = 5) - { - self.socketPath = socketPath - self.maxRequestBytes = maxRequestBytes - self.allowedTeamIDs = allowedTeamIDs - self.requestTimeoutSec = requestTimeoutSec - } - - private static func disableSigPipe(fd: Int32) { - var one: Int32 = 1 - _ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size(ofValue: one))) - } - - func start() { - // Already running - guard self.listenFD == -1 else { return } - - let path = self.socketPath - let fm = FileManager.default - // Ensure directory exists - let dir = (path as NSString).deletingLastPathComponent - try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true) - // Remove stale socket - unlink(path) - - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { return } - - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let capacity = MemoryLayout.size(ofValue: addr.sun_path) - let copied = path.withCString { cstr -> Int in - strlcpy(&addr.sun_path.0, cstr, capacity) - } - if copied >= capacity { - close(fd) - return - } - addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr)) - let len = socklen_t(MemoryLayout.size(ofValue: addr)) - if bind(fd, withUnsafePointer(to: &addr) { UnsafePointer(OpaquePointer($0)) }, len) != 0 { - close(fd) - return - } - // Restrict permissions: owner rw - chmod(path, S_IRUSR | S_IWUSR) - if listen(fd, SOMAXCONN) != 0 { - close(fd) - return - } - - self.listenFD = fd - - let allowedTeamIDs = self.allowedTeamIDs - let maxRequestBytes = self.maxRequestBytes - let requestTimeoutSec = self.requestTimeoutSec - self.acceptTask = Task.detached(priority: .utility) { - await Self.acceptLoop( - listenFD: fd, - allowedTeamIDs: allowedTeamIDs, - maxRequestBytes: maxRequestBytes, - requestTimeoutSec: requestTimeoutSec) - } - } - - func stop() { - self.acceptTask?.cancel() - self.acceptTask = nil - if self.listenFD != -1 { - close(self.listenFD) - self.listenFD = -1 - } - unlink(self.socketPath) - } - - private nonisolated static func acceptLoop( - listenFD: Int32, - allowedTeamIDs: Set, - maxRequestBytes: Int, - requestTimeoutSec: TimeInterval) async - { - while !Task.isCancelled { - var addr = sockaddr() - var len = socklen_t(MemoryLayout.size) - let client = accept(listenFD, &addr, &len) - if client < 0 { - if errno == EINTR { continue } - // Socket was likely closed as part of stop(). - if errno == EBADF || errno == EINVAL { return } - self.logger.error("accept failed: \(errno, privacy: .public)") - try? await Task.sleep(nanoseconds: 50_000_000) - continue - } - - Self.disableSigPipe(fd: client) - Task.detached(priority: .utility) { - defer { close(client) } - await Self.handleClient( - fd: client, - allowedTeamIDs: allowedTeamIDs, - maxRequestBytes: maxRequestBytes, - requestTimeoutSec: requestTimeoutSec) - } - } - } - - private nonisolated static func handleClient( - fd: Int32, - allowedTeamIDs: Set, - maxRequestBytes: Int, - requestTimeoutSec: TimeInterval) async - { - guard self.isAllowed(fd: fd, allowedTeamIDs: allowedTeamIDs) else { - return - } - - do { - guard let request = try self.readRequest( - fd: fd, - maxRequestBytes: maxRequestBytes, - timeoutSec: requestTimeoutSec) - else { - return - } - - let response = try await ControlRequestHandler.process(request: request) - try self.writeResponse(fd: fd, response: response) - } catch { - self.logger.error("socket request failed: \(error.localizedDescription, privacy: .public)") - let resp = Response(ok: false, message: "socket error: \(error.localizedDescription)") - try? self.writeResponse(fd: fd, response: resp) - } - } - - private nonisolated static func readRequest( - fd: Int32, - maxRequestBytes: Int, - timeoutSec: TimeInterval) throws -> Request? - { - let deadline = Date().addingTimeInterval(timeoutSec) - var data = Data() - var buffer = [UInt8](repeating: 0, count: 16 * 1024) - let bufferSize = buffer.count - let decoder = JSONDecoder() - - while true { - let remaining = deadline.timeIntervalSinceNow - if remaining <= 0 { - throw POSIXError(.ETIMEDOUT) - } - - var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) - let sliceMs = max(1.0, min(remaining, 0.25) * 1000.0) - let polled = poll(&pfd, 1, Int32(sliceMs)) - if polled == 0 { continue } - if polled < 0 { - if errno == EINTR { continue } - throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) - } - - let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, bufferSize) } - if n > 0 { - data.append(buffer, count: n) - if data.count > maxRequestBytes { - throw POSIXError(.EMSGSIZE) - } - if let req = try? decoder.decode(Request.self, from: data) { - return req - } - continue - } - - if n == 0 { - return data.isEmpty ? nil : try decoder.decode(Request.self, from: data) - } - - if errno == EINTR { continue } - if errno == EAGAIN { continue } - throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) - } - } - - private nonisolated static func writeResponse(fd: Int32, response: Response) throws { - let encoded = try JSONEncoder().encode(response) - try encoded.withUnsafeBytes { buf in - guard let base = buf.baseAddress else { return } - var written = 0 - while written < encoded.count { - let n = write(fd, base.advanced(by: written), encoded.count - written) - if n > 0 { - written += n - continue - } - if n == -1, errno == EINTR { continue } - throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) - } - } - } - - private nonisolated static func isAllowed(fd: Int32, allowedTeamIDs: Set) -> Bool { - var pid: pid_t = 0 - var pidSize = socklen_t(MemoryLayout.size) - let r = getsockopt(fd, SOL_LOCAL, LOCAL_PEERPID, &pid, &pidSize) - guard r == 0, pid > 0 else { return false } - - // Always require a valid code signature match (TeamID). - // This prevents any same-UID process from driving the app's privileged surface. - if self.teamIDMatches(pid: pid, allowedTeamIDs: allowedTeamIDs) { - return true - } - - #if DEBUG - // Debug-only escape hatch: allow unsigned/same-UID clients when explicitly opted in. - // This keeps local development workable (e.g. a SwiftPM-built `clawdis-mac` binary). - let env = ProcessInfo.processInfo.environment["CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS"] - if env == "1", let callerUID = self.uid(for: pid), callerUID == getuid() { - self.logger.warning( - "allowing unsigned same-UID socket client pid=\(pid) (CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1)") - return true - } - #endif - - if let callerUID = self.uid(for: pid) { - self.logger.error( - "socket client rejected pid=\(pid, privacy: .public) uid=\(callerUID, privacy: .public)") - } else { - self.logger.error("socket client rejected pid=\(pid, privacy: .public) (uid unknown)") - } - return false - } - - private nonisolated static func uid(for pid: pid_t) -> uid_t? { - var info = kinfo_proc() - var size = MemoryLayout.size(ofValue: info) - var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] - let ok = mib.withUnsafeMutableBufferPointer { mibPtr -> Bool in - return sysctl(mibPtr.baseAddress, u_int(mibPtr.count), &info, &size, nil, 0) == 0 - } - return ok ? info.kp_eproc.e_ucred.cr_uid : nil - } - - private nonisolated static func teamIDMatches(pid: pid_t, allowedTeamIDs: Set) -> Bool { - let attrs: NSDictionary = [kSecGuestAttributePid: pid] - var secCode: SecCode? - guard SecCodeCopyGuestWithAttributes(nil, attrs, SecCSFlags(), &secCode) == errSecSuccess, - let code = secCode else { return false } - - var staticCode: SecStaticCode? - guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess, - let sCode = staticCode else { return false } - - var infoCF: CFDictionary? - // `kSecCodeInfoTeamIdentifier` is only included when requesting signing information. - let flags = SecCSFlags(rawValue: UInt32(kSecCSSigningInformation)) - guard SecCodeCopySigningInformation(sCode, flags, &infoCF) == errSecSuccess, - let info = infoCF as? [String: Any], - let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String - else { - return false - } - - return allowedTeamIDs.contains(teamID) - } -} - -#if SWIFT_PACKAGE -extension ControlSocketServer { - nonisolated static func _testTeamIdentifier(pid: pid_t) -> String? { - let attrs: NSDictionary = [kSecGuestAttributePid: pid] - var secCode: SecCode? - guard SecCodeCopyGuestWithAttributes(nil, attrs, SecCSFlags(), &secCode) == errSecSuccess, - let code = secCode else { return nil } - - var staticCode: SecStaticCode? - guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess, - let sCode = staticCode else { return nil } - - var infoCF: CFDictionary? - let flags = SecCSFlags(rawValue: UInt32(kSecCSSigningInformation)) - guard SecCodeCopySigningInformation(sCode, flags, &infoCF) == errSecSuccess, - let info = infoCF as? [String: Any] - else { - return nil - } - - return info[kSecCodeInfoTeamIdentifier as String] as? String - } -} -#endif diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index 0a389f1da..68af05a12 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -296,7 +296,7 @@ struct GeneralSettings: View { .foregroundStyle(.secondary) .lineLimit(2) } else { - Text("Symlink \"clawdis-mac\" into /usr/local/bin and /opt/homebrew/bin for scripts.") + Text("Symlink \"clawdis\" into /usr/local/bin and /opt/homebrew/bin for scripts.") .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) diff --git a/apps/macos/Sources/Clawdis/InstanceIdentity.swift b/apps/macos/Sources/Clawdis/InstanceIdentity.swift index de61a1057..143168b14 100644 --- a/apps/macos/Sources/Clawdis/InstanceIdentity.swift +++ b/apps/macos/Sources/Clawdis/InstanceIdentity.swift @@ -29,7 +29,7 @@ enum InstanceIdentity { { return name } - return "clawdis-mac" + return "clawdis" }() static let modelIdentifier: String? = { diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index bfea2dddb..6bf6ee948 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -202,7 +202,6 @@ private final class StatusItemMouseHandlerView: NSView { final class AppDelegate: NSObject, NSApplicationDelegate { private var state: AppState? private let webChatAutoLogger = Logger(subsystem: "com.steipete.clawdis", category: "Chat") - private let socketServer = ControlSocketServer() let updaterController: UpdaterProviding = makeUpdaterController() func application(_: NSApplication, open urls: [URL]) { @@ -231,7 +230,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Task { PresenceReporter.shared.start() } Task { await HealthStore.shared.refresh(onDemand: true) } Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) } - Task { await self.socketServer.start() } Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) } self.scheduleFirstRunOnboardingIfNeeded() @@ -255,7 +253,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { WebChatManager.shared.resetTunnels() Task { await RemoteTunnelManager.shared.stopAll() } Task { await GatewayConnection.shared.shutdown() } - Task { await self.socketServer.stop() } Task { await PeekabooBridgeHostCoordinator.shared.stop() } } diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift index 8ec9accc3..66de37860 100644 --- a/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift @@ -60,9 +60,10 @@ final class MacNodeModeCoordinator { retryDelay = 1_000_000_000 do { + let hello = await self.makeHello() try await self.session.connect( endpoint: endpoint, - hello: self.makeHello(), + hello: hello, onConnected: { [weak self] serverName in self?.logger.info("mac node connected to \(serverName, privacy: .public)") }, @@ -86,10 +87,11 @@ final class MacNodeModeCoordinator { } } - private func makeHello() -> BridgeHello { + private func makeHello() async -> BridgeHello { let token = MacNodeTokenStore.loadToken() let caps = self.currentCaps() let commands = self.currentCommands(caps: caps) + let permissions = await self.currentPermissions() return BridgeHello( nodeId: Self.nodeId(), displayName: InstanceIdentity.displayName, @@ -99,7 +101,8 @@ final class MacNodeModeCoordinator { deviceFamily: "Mac", modelIdentifier: InstanceIdentity.modelIdentifier, caps: caps, - commands: commands) + commands: commands, + permissions: permissions) } private func currentCaps() -> [String] { @@ -110,6 +113,11 @@ final class MacNodeModeCoordinator { return caps } + private func currentPermissions() async -> [String: Bool] { + let statuses = await PermissionManager.status() + return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) }) + } + private func currentCommands(caps: [String]) -> [String] { var commands: [String] = [ ClawdisCanvasCommand.present.rawValue, @@ -121,6 +129,8 @@ final class MacNodeModeCoordinator { ClawdisCanvasA2UICommand.pushJSONL.rawValue, ClawdisCanvasA2UICommand.reset.rawValue, MacNodeScreenCommand.record.rawValue, + ClawdisSystemCommand.run.rawValue, + ClawdisSystemCommand.notify.rawValue, ] let capsSet = Set(caps) @@ -140,9 +150,10 @@ final class MacNodeModeCoordinator { let shouldSilent = await MainActor.run { AppStateStore.shared.connectionMode == .remote } + let hello = await self.makeHello() let token = try await MacNodeBridgePairingClient().pairAndHello( endpoint: endpoint, - hello: self.makeHello(), + hello: hello, silent: shouldSilent, onStatus: { [weak self] status in self?.logger.info("mac node pairing: \(status, privacy: .public)") diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift index a117b20c6..91a000220 100644 --- a/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift @@ -185,6 +185,12 @@ actor MacNodeRuntime { hasAudio: res.hasAudio)) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + case ClawdisSystemCommand.run.rawValue: + return try await self.handleSystemRun(req) + + case ClawdisSystemCommand.notify.rawValue: + return try await self.handleSystemNotify(req) + default: return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") } @@ -249,6 +255,89 @@ actor MacNodeRuntime { return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON) } + private func handleSystemRun(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let params = try Self.decodeParams(ClawdisSystemRunParams.self, from: req.paramsJSON) + let command = params.command + guard !command.isEmpty else { + return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required") + } + + if params.needsScreenRecording == true { + let authorized = await PermissionManager + .status([.screenRecording])[.screenRecording] ?? false + if !authorized { + return Self.errorResponse( + req, + code: .unavailable, + message: "PERMISSION_MISSING: screenRecording") + } + } + + let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 } + let result = await ShellExecutor.runDetailed( + command: command, + cwd: params.cwd, + env: params.env, + timeout: timeoutSec) + + struct RunPayload: Encodable { + var exitCode: Int? + var timedOut: Bool + var success: Bool + var stdout: String + var stderr: String + var error: String? + } + + let payload = try Self.encodePayload(RunPayload( + exitCode: result.exitCode, + timedOut: result.timedOut, + success: result.success, + stdout: result.stdout, + stderr: result.stderr, + error: result.errorMessage)) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + } + + private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let params = try Self.decodeParams(ClawdisSystemNotifyParams.self, from: req.paramsJSON) + let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + if title.isEmpty && body.isEmpty { + return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: empty notification") + } + + let priority = params.priority.flatMap { NotificationPriority(rawValue: $0.rawValue) } + let delivery = params.delivery.flatMap { NotificationDelivery(rawValue: $0.rawValue) } ?? .system + let manager = NotificationManager() + + switch delivery { + case .system: + let ok = await manager.send( + title: title, + body: body, + sound: params.sound, + priority: priority) + return ok + ? BridgeInvokeResponse(id: req.id, ok: true) + : Self.errorResponse(req, code: .unavailable, message: "NOT_AUTHORIZED: notifications") + case .overlay: + await NotifyOverlayController.shared.present(title: title, body: body) + return BridgeInvokeResponse(id: req.id, ok: true) + case .auto: + let ok = await manager.send( + title: title, + body: body, + sound: params.sound, + priority: priority) + if ok { + return BridgeInvokeResponse(id: req.id, ok: true) + } + await NotifyOverlayController.shared.present(title: title, body: body) + return BridgeInvokeResponse(id: req.id, ok: true) + } + } + private static func decodeParams(_ type: T.Type, from json: String?) throws -> T { guard let json, let data = json.data(using: .utf8) else { throw NSError(domain: "Bridge", code: 20, userInfo: [ diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index b74870c54..f4a24bab3 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -107,7 +107,8 @@ struct OnboardingView: View { } private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" } - private let devLinkCommand = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac" + private let devLinkCommand = + "ln -sf /Applications/Clawdis.app/Contents/Resources/Relay/clawdis /usr/local/bin/clawdis" init( state: AppState = AppStateStore.shared, @@ -897,7 +898,7 @@ struct OnboardingView: View { self.onboardingPage { Text("Install the helper CLI") .font(.largeTitle.weight(.semibold)) - Text("Optional, but recommended: link `clawdis-mac` so scripts can talk to this app.") + Text("Optional, but recommended: link `clawdis` so scripts can reach the local gateway.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -912,7 +913,7 @@ struct OnboardingView: View { if self.installingCLI { ProgressView() } else { - Text(self.cliInstalled ? "Reinstall helper" : "Install helper") + Text(self.cliInstalled ? "Reinstall CLI" : "Install CLI") } } .buttonStyle(.borderedProminent) diff --git a/apps/macos/Sources/Clawdis/ShellExecutor.swift b/apps/macos/Sources/Clawdis/ShellExecutor.swift index 893924d05..bb6d50b52 100644 --- a/apps/macos/Sources/Clawdis/ShellExecutor.swift +++ b/apps/macos/Sources/Clawdis/ShellExecutor.swift @@ -2,8 +2,30 @@ import ClawdisIPC import Foundation enum ShellExecutor { - static func run(command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> Response { - guard !command.isEmpty else { return Response(ok: false, message: "empty command") } + struct ShellResult { + var stdout: String + var stderr: String + var exitCode: Int? + var timedOut: Bool + var success: Bool + var errorMessage: String? + } + + static func runDetailed( + command: [String], + cwd: String?, + env: [String: String]?, + timeout: Double?) async -> ShellResult + { + guard !command.isEmpty else { + return ShellResult( + stdout: "", + stderr: "", + exitCode: nil, + timedOut: false, + success: false, + errorMessage: "empty command") + } let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") @@ -19,36 +41,59 @@ enum ShellExecutor { do { try process.run() } catch { - return Response(ok: false, message: "failed to start: \(error.localizedDescription)") + return ShellResult( + stdout: "", + stderr: "", + exitCode: nil, + timedOut: false, + success: false, + errorMessage: "failed to start: \(error.localizedDescription)") } - let waitTask = Task { () -> Response in + let waitTask = Task { () -> ShellResult in process.waitUntilExit() let out = stdoutPipe.fileHandleForReading.readToEndSafely() let err = stderrPipe.fileHandleForReading.readToEndSafely() - let status = process.terminationStatus - let combined = out.isEmpty ? err : out - return Response(ok: status == 0, message: status == 0 ? nil : "exit \(status)", payload: combined) + let status = Int(process.terminationStatus) + return ShellResult( + stdout: String(decoding: out, as: UTF8.self), + stderr: String(decoding: err, as: UTF8.self), + exitCode: status, + timedOut: false, + success: status == 0, + errorMessage: status == 0 ? nil : "exit \(status)") } if let timeout, timeout > 0 { let nanos = UInt64(timeout * 1_000_000_000) - let response = await withTaskGroup(of: Response.self) { group in + let result = await withTaskGroup(of: ShellResult.self) { group in group.addTask { await waitTask.value } group.addTask { try? await Task.sleep(nanoseconds: nanos) if process.isRunning { process.terminate() } _ = await waitTask.value // drain pipes after termination - return Response(ok: false, message: "timeout") + return ShellResult( + stdout: "", + stderr: "", + exitCode: nil, + timedOut: true, + success: false, + errorMessage: "timeout") } - // Whichever completes first (process exit or timeout) wins; cancel the other branch. let first = await group.next()! group.cancelAll() return first } - return response + return result } return await waitTask.value } + + static func run(command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> Response { + let result = await self.runDetailed(command: command, cwd: cwd, env: env, timeout: timeout) + let combined = result.stdout.isEmpty ? result.stderr : result.stdout + let payload = combined.isEmpty ? nil : Data(combined.utf8) + return Response(ok: result.success, message: result.errorMessage, payload: payload) + } } diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index 01140811f..1987fde1f 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -137,7 +137,7 @@ enum CLIInstaller { let fm = FileManager.default for basePath in cliHelperSearchPaths { - let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis-mac").path + let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis").path var isDirectory: ObjCBool = false guard fm.fileExists(atPath: candidate, isDirectory: &isDirectory), !isDirectory.boolValue else { @@ -157,13 +157,13 @@ enum CLIInstaller { } static func install(statusHandler: @escaping @Sendable (String) async -> Void) async { - let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/ClawdisCLI") + let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis") guard FileManager.default.isExecutableFile(atPath: helper.path) else { await statusHandler("Helper missing in bundle; rebuild via scripts/package-mac-app.sh") return } - let targets = cliHelperSearchPaths.map { "\($0)/clawdis-mac" } + let targets = cliHelperSearchPaths.map { "\($0)/clawdis" } let result = await self.privilegedSymlink(source: helper.path, targets: targets) await statusHandler(result) } @@ -432,25 +432,6 @@ enum CommandResolver { } } - static func clawdisMacCommand( - subcommand: String, - extraArgs: [String] = [], - defaults: UserDefaults = .standard) -> [String] - { - let settings = self.connectionSettings(defaults: defaults) - if settings.mode == .remote, let ssh = self.sshMacHelperCommand( - subcommand: subcommand, - extraArgs: extraArgs, - settings: settings) - { - return ssh - } - if let helper = self.findExecutable(named: "clawdis-mac") { - return [helper, subcommand] + extraArgs - } - return ["/usr/local/bin/clawdis-mac", subcommand] + extraArgs - } - // Existing callers still refer to clawdisCommand; keep it as node alias. static func clawdisCommand( subcommand: String, @@ -474,7 +455,7 @@ enum CommandResolver { let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host args.append(userHost) - // Run the real clawdis CLI on the remote host; do not fall back to clawdis-mac. + // Run the real clawdis CLI on the remote host. let exportedPath = [ "/opt/homebrew/bin", "/usr/local/bin", @@ -535,38 +516,6 @@ enum CommandResolver { return ["/usr/bin/ssh"] + args } - private static func sshMacHelperCommand( - subcommand: String, - extraArgs: [String], - settings: RemoteSettings) -> [String]? - { - guard !settings.target.isEmpty else { return nil } - guard let parsed = self.parseSSHTarget(settings.target) 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 exportedPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" - let userPRJ = settings.projectRoot - let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ") - let scriptBody = """ - PATH=\(exportedPath); - PRJ=\(userPRJ.isEmpty ? "" : self.shellQuote(userPRJ)) - DEFAULT_PRJ="$HOME/Projects/clawdis" - if [ -z "${PRJ:-}" ] && [ -d "$DEFAULT_PRJ" ]; then PRJ="$DEFAULT_PRJ"; fi - if [ -n "${PRJ:-}" ]; then cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }; fi - if ! command -v clawdis-mac >/dev/null 2>&1; then echo "clawdis-mac missing on remote host"; exit 127; fi; - clawdis-mac \(quotedArgs) - """ - args.append(contentsOf: ["/bin/sh", "-c", scriptBody]) - return ["/usr/bin/ssh"] + args - } - struct RemoteSettings { let mode: AppState.ConnectionMode let target: String diff --git a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift deleted file mode 100644 index 5eecb0865..000000000 --- a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift +++ /dev/null @@ -1,1057 +0,0 @@ -import ClawdisIPC -import Darwin -import Foundation - -// swiftlint:disable type_body_length -@main -struct ClawdisCLI { - static func main() async { - do { - var args = Array(CommandLine.arguments.dropFirst()) - let jsonOutput = args.contains("--json") - args.removeAll(where: { $0 == "--json" }) - - let parsed = try parseCommandLine(args: args) - let response = try await send(request: parsed.request) - - if jsonOutput { - try self.printJSON(parsed: parsed, response: response) - } else { - try self.printText(parsed: parsed, response: response) - } - - exit(response.ok ? 0 : 1) - } catch CLIError.help { - self.printHelp() - exit(0) - } catch CLIError.version { - self.printVersion() - exit(0) - } catch { - // Keep errors readable for CLI + SSH callers; print full domains/codes only when asked. - let verbose = ProcessInfo.processInfo.environment["CLAWDIS_MAC_VERBOSE_ERRORS"] == "1" - if verbose { - fputs("clawdis-mac error: \(error)\n", stderr) - } else { - let ns = error as NSError - let message = ns.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) - let desc = message.isEmpty ? String(describing: error) : message - fputs("clawdis-mac error: \(desc) (\(ns.domain), \(ns.code))\n", stderr) - } - exit(2) - } - } - - private struct ParsedCLIRequest { - var request: Request - var kind: Kind - var verbose: Bool = false - - enum Kind { - case generic - case mediaPath - } - } - - private static func parseCommandLine(args: [String]) throws -> ParsedCLIRequest { - var args = args - guard !args.isEmpty else { throw CLIError.help } - let command = args.removeFirst() - - switch command { - case "--help", "-h", "help": - throw CLIError.help - - case "--version", "-V", "version": - throw CLIError.version - - case "notify": - return try self.parseNotify(args: &args) - - case "ensure-permissions": - return self.parseEnsurePermissions(args: &args) - - case "run": - return self.parseRunShell(args: &args) - - case "status": - return ParsedCLIRequest(request: .status, kind: .generic) - - case "rpc-status": - return ParsedCLIRequest(request: .rpcStatus, kind: .generic) - - case "agent": - return try self.parseAgent(args: &args) - - case "node": - return try self.parseNode(args: &args) - - case "canvas": - return try self.parseCanvas(args: &args) - - case "camera": - return try self.parseCamera(args: &args) - - case "screen": - return try self.parseScreen(args: &args) - - default: - throw CLIError.help - } - } - - private static func parseDurationMsArg(_ raw: String?) throws -> Int? { - guard let raw else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if trimmed.isEmpty { return nil } - - let regex = try NSRegularExpression(pattern: "^(\\d+(?:\\.\\d+)?)(ms|s|m)?$") - let range = NSRange(trimmed.startIndex..= 0 else { - throw NSError(domain: "ClawdisCLI", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "invalid duration: \(raw) (expected 1000, 10s, 1m)", - ]) - } - - let unit: String = { - if let unitRange = Range(match.range(at: 2), in: trimmed) { - return String(trimmed[unitRange]) - } - return "ms" - }() - - let multiplier: Double = switch unit { - case "ms": 1 - case "s": 1000 - case "m": 60000 - default: 1 - } - - let ms = Int((value * multiplier).rounded()) - guard ms >= 0 else { - throw NSError(domain: "ClawdisCLI", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "invalid duration: \(raw) (expected 1000, 10s, 1m)", - ]) - } - return ms - } - - private static func parseNotify(args: inout [String]) throws -> ParsedCLIRequest { - var title: String? - var body: String? - var sound: String? - var priority: NotificationPriority? - var delivery: NotificationDelivery? - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--title": title = args.popFirst() - case "--body": body = args.popFirst() - case "--sound": sound = args.popFirst() - case "--priority": - if let val = args.popFirst(), let p = NotificationPriority(rawValue: val) { priority = p } - case "--delivery": - if let val = args.popFirst(), let d = NotificationDelivery(rawValue: val) { delivery = d } - default: break - } - } - guard let t = title, let b = body else { throw CLIError.help } - return ParsedCLIRequest( - request: .notify(title: t, body: b, sound: sound, priority: priority, delivery: delivery), - kind: .generic) - } - - private static func parseEnsurePermissions(args: inout [String]) -> ParsedCLIRequest { - var caps: [Capability] = [] - var interactive = false - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--cap": - if let val = args.popFirst(), let cap = Capability(rawValue: val) { caps.append(cap) } - case "--interactive": - interactive = true - default: - break - } - } - if caps.isEmpty { caps = Capability.allCases } - return ParsedCLIRequest(request: .ensurePermissions(caps, interactive: interactive), kind: .generic) - } - - private static func parseRunShell(args: inout [String]) -> ParsedCLIRequest { - var cwd: String? - var env: [String: String] = [:] - var timeout: Double? - var needsSR = false - var cmd: [String] = [] - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--cwd": - cwd = args.popFirst() - case "--env": - if let pair = args.popFirst() { - self.parseEnvPair(pair, into: &env) - } - case "--timeout": - if let val = args.popFirst(), let dbl = Double(val) { timeout = dbl } - case "--needs-screen-recording": - needsSR = true - default: - cmd.append(arg) - } - } - return ParsedCLIRequest( - request: .runShell( - command: cmd, - cwd: cwd, - env: env.isEmpty ? nil : env, - timeoutSec: timeout, - needsScreenRecording: needsSR), - kind: .generic) - } - - private static func parseEnvPair(_ pair: String, into env: inout [String: String]) { - guard let eq = pair.firstIndex(of: "=") else { return } - let key = String(pair[.. ParsedCLIRequest { - var message: String? - var thinking: String? - var session: String? - var deliver = false - var to: String? - - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--message": message = args.popFirst() - case "--thinking": thinking = args.popFirst() - case "--session": session = args.popFirst() - case "--deliver": deliver = true - case "--to": to = args.popFirst() - default: - if message == nil { - message = arg - } - } - } - - guard let message else { throw CLIError.help } - return ParsedCLIRequest( - request: .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to), - kind: .generic) - } - - private static func parseNode(args: inout [String]) throws -> ParsedCLIRequest { - guard let sub = args.popFirst() else { throw CLIError.help } - switch sub { - case "list": - var verbose = false - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--verbose": - verbose = true - default: - break - } - } - return ParsedCLIRequest(request: .nodeList, kind: .generic, verbose: verbose) - - case "describe": - var nodeId: String? - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--node": - nodeId = args.popFirst() - default: - if nodeId == nil { nodeId = arg } - } - } - guard let nodeId else { throw CLIError.help } - return ParsedCLIRequest(request: .nodeDescribe(nodeId: nodeId), kind: .generic) - - case "invoke": - var nodeId: String? - var command: String? - var paramsJSON: String? - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--node": nodeId = args.popFirst() - case "--command": command = args.popFirst() - case "--params-json": paramsJSON = args.popFirst() - default: break - } - } - guard let nodeId, let command else { throw CLIError.help } - return ParsedCLIRequest( - request: .nodeInvoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON), - kind: .generic) - - default: - throw CLIError.help - } - } - - private static func parseCanvas(args: inout [String]) throws -> ParsedCLIRequest { - guard let sub = args.popFirst() else { throw CLIError.help } - switch sub { - case "present": - var session = "main" - var target: String? - let placement = self.parseCanvasPlacement(args: &args, session: &session, target: &target) - return ParsedCLIRequest( - request: .canvasPresent(session: session, path: target, placement: placement), - kind: .generic) - case "a2ui": - return try self.parseCanvasA2UI(args: &args) - case "hide": - var session = "main" - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--session": session = args.popFirst() ?? session - default: break - } - } - return ParsedCLIRequest(request: .canvasHide(session: session), kind: .generic) - case "eval": - var session = "main" - var js: String? - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--session": session = args.popFirst() ?? session - case "--js": js = args.popFirst() - default: break - } - } - guard let js else { throw CLIError.help } - return ParsedCLIRequest(request: .canvasEval(session: session, javaScript: js), kind: .generic) - case "snapshot": - var session = "main" - var outPath: String? - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--session": session = args.popFirst() ?? session - case "--out": outPath = args.popFirst() - default: break - } - } - return ParsedCLIRequest(request: .canvasSnapshot(session: session, outPath: outPath), kind: .generic) - default: - throw CLIError.help - } - } - - private static func parseCanvasA2UI(args: inout [String]) throws -> ParsedCLIRequest { - guard let sub = args.popFirst() else { throw CLIError.help } - switch sub { - case "push": - var session = "main" - var jsonlPath: String? - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--session": session = args.popFirst() ?? session - case "--jsonl": jsonlPath = args.popFirst() - default: break - } - } - guard let jsonlPath else { throw CLIError.help } - let jsonl = try String(contentsOfFile: jsonlPath, encoding: .utf8) - return ParsedCLIRequest( - request: .canvasA2UI(session: session, command: .pushJSONL, jsonl: jsonl), - kind: .generic) - - case "reset": - var session = "main" - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--session": session = args.popFirst() ?? session - default: break - } - } - return ParsedCLIRequest( - request: .canvasA2UI(session: session, command: .reset, jsonl: nil), - kind: .generic) - - default: - throw CLIError.help - } - } - - private static func parseCamera(args: inout [String]) throws -> ParsedCLIRequest { - guard let sub = args.popFirst() else { throw CLIError.help } - switch sub { - case "snap": - var facing: CameraFacing? - var maxWidth: Int? - var quality: Double? - var outPath: String? - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--facing": - if let val = args.popFirst(), let f = CameraFacing(rawValue: val) { facing = f } - case "--max-width": - maxWidth = args.popFirst().flatMap(Int.init) - case "--quality": - quality = args.popFirst().flatMap(Double.init) - case "--out": - outPath = args.popFirst() - default: - break - } - } - return ParsedCLIRequest( - request: .cameraSnap(facing: facing, maxWidth: maxWidth, quality: quality, outPath: outPath), - kind: .mediaPath) - - case "clip": - var facing: CameraFacing? - var durationMs: Int? - var includeAudio = true - var outPath: String? - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--facing": - if let val = args.popFirst(), let f = CameraFacing(rawValue: val) { facing = f } - case "--duration": - durationMs = try self.parseDurationMsArg(args.popFirst()) - case "--duration-ms": - durationMs = args.popFirst().flatMap(Int.init) - case "--no-audio": - includeAudio = false - case "--out": - outPath = args.popFirst() - default: - break - } - } - return ParsedCLIRequest( - request: .cameraClip( - facing: facing, - durationMs: durationMs, - includeAudio: includeAudio, - outPath: outPath), - kind: .mediaPath) - - default: - throw CLIError.help - } - } - - private static func parseScreen(args: inout [String]) throws -> ParsedCLIRequest { - guard let sub = args.popFirst() else { throw CLIError.help } - switch sub { - case "record": - var screenIndex: Int? - var durationMs: Int? - var fps: Double? - var outPath: String? - var includeAudio = true - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--screen": - screenIndex = args.popFirst().flatMap(Int.init) - case "--duration": - durationMs = try self.parseDurationMsArg(args.popFirst()) - case "--duration-ms": - durationMs = args.popFirst().flatMap(Int.init) - case "--fps": - fps = args.popFirst().flatMap(Double.init) - case "--no-audio": - includeAudio = false - case "--out": - outPath = args.popFirst() - default: - break - } - } - return ParsedCLIRequest( - request: .screenRecord( - screenIndex: screenIndex, - durationMs: durationMs, - fps: fps, - includeAudio: includeAudio, - outPath: outPath), - kind: .mediaPath) - - default: - throw CLIError.help - } - } - - private static func parseCanvasPlacement( - args: inout [String], - session: inout String, - target: inout String?) -> CanvasPlacement? - { - var x: Double? - var y: Double? - var width: Double? - var height: Double? - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--session": session = args.popFirst() ?? session - case "--target", "--path": target = args.popFirst() - case "--x": x = args.popFirst().flatMap(Double.init) - case "--y": y = args.popFirst().flatMap(Double.init) - case "--width": width = args.popFirst().flatMap(Double.init) - case "--height": height = args.popFirst().flatMap(Double.init) - default: break - } - } - if x == nil, y == nil, width == nil, height == nil { return nil } - return CanvasPlacement(x: x, y: y, width: width, height: height) - } - - // swiftlint:disable:next cyclomatic_complexity - private static func printText(parsed: ParsedCLIRequest, response: Response) throws { - guard response.ok else { - let msg = response.message ?? "failed" - fputs("\(msg)\n", stderr) - return - } - - if case .canvasPresent = parsed.request { - if let message = response.message, !message.isEmpty { - FileHandle.standardOutput.write(Data((message + "\n").utf8)) - } - if let payload = response.payload, let info = try? JSONDecoder().decode( - CanvasShowResult.self, - from: payload) - { - FileHandle.standardOutput.write(Data("STATUS:\(info.status.rawValue)\n".utf8)) - if let url = info.url, !url.isEmpty { - FileHandle.standardOutput.write(Data("URL:\(url)\n".utf8)) - } - } - return - } - - if case .nodeList = parsed.request, let payload = response.payload { - struct NodeListResult: Decodable { - struct Node: Decodable { - var nodeId: String - var displayName: String? - var platform: String? - var version: String? - var deviceFamily: String? - var modelIdentifier: String? - var remoteAddress: String? - var connected: Bool - var paired: Bool? - var capabilities: [String]? - var commands: [String]? - } - - var pairedNodeIds: [String]? - var connectedNodeIds: [String]? - var nodes: [Node] - } - - if let decoded = try? JSONDecoder().decode(NodeListResult.self, from: payload) { - let pairedCount = decoded.pairedNodeIds?.count ?? decoded.nodes.count - let connectedCount = decoded.connectedNodeIds?.count ?? decoded.nodes.filter(\.connected).count - print("Paired: \(pairedCount) · Connected: \(connectedCount)") - - for n in decoded.nodes { - let nameTrimmed = n.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let name = nameTrimmed.isEmpty ? n.nodeId : nameTrimmed - - let ipTrimmed = n.remoteAddress?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let ip = ipTrimmed.isEmpty ? nil : ipTrimmed - - let familyTrimmed = n.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let family = familyTrimmed.isEmpty ? nil : familyTrimmed - let modelTrimmed = n.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let model = modelTrimmed.isEmpty ? nil : modelTrimmed - - let caps = n.capabilities?.sorted().joined(separator: ",") - let capsText = caps.map { "[\($0)]" } ?? "?" - - var parts: [String] = ["- \(name)", n.nodeId] - if let ip { parts.append(ip) } - if let family { parts.append("device: \(family)") } - if let model { parts.append("hw: \(model)") } - let paired = n.paired ?? true - parts.append(paired ? "paired" : "unpaired") - parts.append(n.connected ? "connected" : "disconnected") - parts.append("caps: \(capsText)") - print(parts.joined(separator: " · ")) - - if parsed.verbose { - let platform = (n.platform ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let version = (n.version ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if !platform.isEmpty || !version.isEmpty { - let pv = [platform.isEmpty ? nil : platform, version.isEmpty ? nil : version] - .compactMap(\.self) - .joined(separator: " ") - if !pv.isEmpty { print(" platform: \(pv)") } - } - - let commands = n.commands?.sorted() ?? [] - if !commands.isEmpty { - print(" commands: \(commands.joined(separator: ", "))") - } - } - } - return - } - } - - if case .nodeDescribe = parsed.request, let payload = response.payload { - struct NodeDescribeResult: Decodable { - var nodeId: String - var displayName: String? - var platform: String? - var version: String? - var deviceFamily: String? - var modelIdentifier: String? - var remoteIp: String? - var caps: [String]? - var commands: [String]? - var paired: Bool? - var connected: Bool? - } - - if let decoded = try? JSONDecoder().decode(NodeDescribeResult.self, from: payload) { - let nameTrimmed = decoded.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let name = nameTrimmed.isEmpty ? decoded.nodeId : nameTrimmed - - let ipTrimmed = decoded.remoteIp?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let ip = ipTrimmed.isEmpty ? nil : ipTrimmed - - let familyTrimmed = decoded.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let family = familyTrimmed.isEmpty ? nil : familyTrimmed - let modelTrimmed = decoded.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let model = modelTrimmed.isEmpty ? nil : modelTrimmed - - let caps = decoded.caps?.sorted().joined(separator: ",") - let capsText = caps.map { "[\($0)]" } ?? "?" - let commands = decoded.commands?.sorted() ?? [] - - var parts: [String] = ["Node:", name, decoded.nodeId] - if let ip { parts.append(ip) } - if let family { parts.append("device: \(family)") } - if let model { parts.append("hw: \(model)") } - if let paired = decoded.paired { parts.append(paired ? "paired" : "unpaired") } - if let connected = decoded.connected { parts.append(connected ? "connected" : "disconnected") } - parts.append("caps: \(capsText)") - print(parts.joined(separator: " · ")) - if !commands.isEmpty { - print("Commands:") - for c in commands { - print("- \(c)") - } - } - return - } - } - - switch parsed.kind { - case .generic: - if let payload = response.payload, let text = String(data: payload, encoding: .utf8), !text.isEmpty { - FileHandle.standardOutput.write(payload) - if !text.hasSuffix("\n") { FileHandle.standardOutput.write(Data([0x0A])) } - return - } - if let message = response.message, !message.isEmpty { - FileHandle.standardOutput.write(Data((message + "\n").utf8)) - } - case .mediaPath: - if let message = response.message, !message.isEmpty { - print("MEDIA:\(message)") - } - } - } - - private static func printJSON(parsed: ParsedCLIRequest, response: Response) throws { - var output: [String: Any] = [ - "ok": response.ok, - "message": response.message ?? "", - ] - - switch parsed.kind { - case .generic: - if let payload = response.payload, !payload.isEmpty { - if let obj = try? JSONSerialization.jsonObject(with: payload) { - output["result"] = obj - } else if let text = String(data: payload, encoding: .utf8) { - output["payload"] = text - } - } - case .mediaPath: - break - } - - let json = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted]) - FileHandle.standardOutput.write(json) - FileHandle.standardOutput.write(Data([0x0A])) - } - - private static func decodePayload(_ type: T.Type, payload: Data?) throws -> T { - guard let payload else { throw POSIXError(.EINVAL) } - return try JSONDecoder().decode(T.self, from: payload) - } - - private static func printHelp() { - let usage = """ - clawdis-mac — talk to the running Clawdis.app (local control socket) - - Usage: - clawdis-mac [--json] ... - - Commands: - Notifications: - clawdis-mac notify --title --body [--sound ] - [--priority ] [--delivery ] - - Permissions: - clawdis-mac ensure-permissions - [--cap ] - [--interactive] - - Shell: - clawdis-mac run [--cwd ] [--env KEY=VAL] [--timeout ] - [--needs-screen-recording] - - Status: - clawdis-mac status - clawdis-mac rpc-status - - Agent: - clawdis-mac agent --message [--thinking ] - [--session ] [--deliver] [--to ] - - Nodes: - clawdis-mac node list [--verbose] # paired + connected nodes (+ capabilities when available) - clawdis-mac node describe --node - clawdis-mac node invoke --node --command [--params-json ] - - Canvas: - clawdis-mac canvas present [--session ] [--target ] - [--x --y ] [--width --height ] - clawdis-mac canvas a2ui push --jsonl [--session ] # A2UI v0.8 JSONL - clawdis-mac canvas a2ui reset [--session ] - clawdis-mac canvas hide [--session ] - clawdis-mac canvas eval --js [--session ] - clawdis-mac canvas snapshot [--out ] [--session ] - - Camera: - clawdis-mac camera snap [--facing ] [--max-width ] [--quality <0-1>] [--out ] - clawdis-mac camera clip [--facing ] - [--duration |--duration-ms ] [--no-audio] [--out ] - - Screen: - clawdis-mac screen record [--screen ] - [--duration |--duration-ms ] [--fps ] [--no-audio] [--out ] - - UI Automation (Peekaboo): - Install and use the `peekaboo` CLI; it will connect to Peekaboo.app (preferred) or Clawdis.app - (fallback) via PeekabooBridge. See `docs/mac/peekaboo.md`. - - Examples: - clawdis-mac status - clawdis-mac agent --message "Hello from clawd" --thinking low - - Output: - Default output is text. Use --json for machine-readable output. - In text mode, `camera snap`, `camera clip`, and `screen record` print MEDIA:. - """ - print(usage) - } - - private static func printVersion() { - let info = self.loadInfo() - let version = (info["CFBundleShortVersionString"] as? String) ?? self.loadPackageJSONVersion() ?? "unknown" - var build = info["CFBundleVersion"] as? String ?? "" - if build.isEmpty, version != "unknown" { - build = version - } - let git = info["ClawdisGitCommit"] as? String ?? "unknown" - let ts = info["ClawdisBuildTimestamp"] as? String ?? "unknown" - - let buildPart = build.isEmpty ? "" : " (\(build))" - print("clawdis-mac \(version)\(buildPart) git:\(git) built:\(ts)") - } - - private static func loadInfo() -> [String: Any] { - if let dict = Bundle.main.infoDictionary, !dict.isEmpty { return dict } - - guard let exeURL = self.resolveExecutableURL() else { return [:] } - - var dir = exeURL.deletingLastPathComponent() - for _ in 0..<10 { - let candidate = dir.appendingPathComponent("Info.plist") - if let dict = self.loadPlistDictionary(at: candidate) { - return dict - } - let parent = dir.deletingLastPathComponent() - if parent.path == dir.path { break } - dir = parent - } - - return [:] - } - - private static func loadPlistDictionary(at url: URL) -> [String: Any]? { - guard let data = try? Data(contentsOf: url) else { return nil } - return try? PropertyListSerialization - .propertyList(from: data, options: [], format: nil) as? [String: Any] - } - - private static func resolveExecutableURL() -> URL? { - var size = UInt32(PATH_MAX) - var buffer = [CChar](repeating: 0, count: Int(size)) - - let result = buffer.withUnsafeMutableBufferPointer { ptr in - _NSGetExecutablePath(ptr.baseAddress, &size) - } - - if result != 0 { - buffer = [CChar](repeating: 0, count: Int(size)) - let result2 = buffer.withUnsafeMutableBufferPointer { ptr in - _NSGetExecutablePath(ptr.baseAddress, &size) - } - guard result2 == 0 else { return nil } - } - - let nulIndex = buffer.firstIndex(of: 0) ?? buffer.count - let bytes = buffer.prefix(nulIndex).map { UInt8(bitPattern: $0) } - guard let path = String(bytes: bytes, encoding: .utf8) else { return nil } - return URL(fileURLWithPath: path).resolvingSymlinksInPath() - } - - private static func loadPackageJSONVersion() -> String? { - guard let exeURL = self.resolveExecutableURL() else { return nil } - - var dir = exeURL.deletingLastPathComponent() - for _ in 0..<12 { - let candidate = dir.appendingPathComponent("package.json") - if let version = self.loadPackageJSONVersion(at: candidate) { - return version - } - let parent = dir.deletingLastPathComponent() - if parent.path == dir.path { break } - dir = parent - } - - return nil - } - - private static func loadPackageJSONVersion(at url: URL) -> String? { - guard let data = try? Data(contentsOf: url) else { return nil } - guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } - guard obj["name"] as? String == "clawdis" else { return nil } - return obj["version"] as? String - } - - private static func send(request: Request) async throws -> Response { - try await self.ensureAppRunning() - - let timeout = self.rpcTimeoutSeconds(for: request) - return try await self.sendViaSocket(request: request, timeoutSeconds: timeout) - } - - /// Attempt a direct UNIX socket call; falls back to XPC if unavailable. - private static func sendViaSocket(request: Request, timeoutSeconds: TimeInterval) async throws -> Response { - let path = controlSocketPath - let deadline = Date().addingTimeInterval(timeoutSeconds) - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { throw POSIXError(.ECONNREFUSED) } - defer { close(fd) } - - var noSigPipe: Int32 = 1 - _ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size(ofValue: noSigPipe))) - - let flags = fcntl(fd, F_GETFL) - if flags != -1 { - _ = fcntl(fd, F_SETFL, flags | O_NONBLOCK) - } - - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let capacity = MemoryLayout.size(ofValue: addr.sun_path) - let copied = path.withCString { cstr -> Int in - strlcpy(&addr.sun_path.0, cstr, capacity) - } - guard copied < capacity else { throw POSIXError(.ENAMETOOLONG) } - addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr)) - let len = socklen_t(MemoryLayout.size(ofValue: addr)) - let result = withUnsafePointer(to: &addr) { ptr -> Int32 in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in - connect(fd, sockPtr, len) - } - } - if result != 0 { - let err = errno - if err == EINPROGRESS { - try self.waitForSocket( - fd: fd, - events: Int16(POLLOUT), - until: deadline, - timeoutSeconds: timeoutSeconds) - var soError: Int32 = 0 - var soLen = socklen_t(MemoryLayout.size(ofValue: soError)) - _ = getsockopt(fd, SOL_SOCKET, SO_ERROR, &soError, &soLen) - if soError != 0 { throw POSIXError(POSIXErrorCode(rawValue: soError) ?? .ECONNREFUSED) } - } else { - throw POSIXError(POSIXErrorCode(rawValue: err) ?? .ECONNREFUSED) - } - } - - let payload = try JSONEncoder().encode(request) - try payload.withUnsafeBytes { buf in - guard let base = buf.baseAddress else { return } - var written = 0 - while written < payload.count { - try self.ensureDeadline(deadline, timeoutSeconds: timeoutSeconds) - let n = write(fd, base.advanced(by: written), payload.count - written) - if n > 0 { - written += n - continue - } - if n == -1, errno == EINTR { continue } - if n == -1, errno == EAGAIN { - try self.waitForSocket( - fd: fd, - events: Int16(POLLOUT), - until: deadline, - timeoutSeconds: timeoutSeconds) - continue - } - throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) - } - } - shutdown(fd, SHUT_WR) - - var data = Data() - let decoder = JSONDecoder() - var buffer = [UInt8](repeating: 0, count: 8192) - let bufSize = buffer.count - while true { - try self.ensureDeadline(deadline, timeoutSeconds: timeoutSeconds) - try self.waitForSocket( - fd: fd, - events: Int16(POLLIN), - until: deadline, - timeoutSeconds: timeoutSeconds) - let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, bufSize) } - if n > 0 { - data.append(buffer, count: n) - if let resp = try? decoder.decode(Response.self, from: data) { - return resp - } - continue - } - if n == 0 { break } - if n == -1, errno == EINTR { continue } - if n == -1, errno == EAGAIN { continue } - throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) - } - guard !data.isEmpty else { throw POSIXError(.ECONNRESET) } - return try decoder.decode(Response.self, from: data) - } - - private static func rpcTimeoutSeconds(for request: Request) -> TimeInterval { - switch request { - case let .runShell(_, _, _, timeoutSec, _): - // Allow longer for commands; still cap overall to a sane bound. - return min(300, max(10, (timeoutSec ?? 10) + 2)) - case let .cameraClip(_, durationMs, _, _): - let ms = durationMs ?? 3000 - return min(180, max(10, TimeInterval(ms) / 1000.0 + 10)) - case let .screenRecord(_, durationMs, _, _, _): - let ms = durationMs ?? 10000 - return min(180, max(10, TimeInterval(ms) / 1000.0 + 10)) - default: - // Fail-fast so callers (incl. SSH tool calls) don't hang forever. - return 10 - } - } - - private static func ensureDeadline(_ deadline: Date, timeoutSeconds: TimeInterval) throws { - if Date() >= deadline { - throw CLITimeoutError(seconds: timeoutSeconds) - } - } - - private static func waitForSocket( - fd: Int32, - events: Int16, - until deadline: Date, - timeoutSeconds: TimeInterval) throws - { - while true { - let remaining = deadline.timeIntervalSinceNow - if remaining <= 0 { throw CLITimeoutError(seconds: timeoutSeconds) } - var pfd = pollfd(fd: fd, events: events, revents: 0) - let ms = Int32(max(1, min(remaining, 0.5) * 1000)) // small slices so we enforce total timeout - let n = poll(&pfd, 1, ms) - if n > 0 { return } - if n == 0 { continue } - if errno == EINTR { continue } - throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) - } - } - - private static func ensureAppRunning() async throws { - let appURL = URL(fileURLWithPath: CommandLine.arguments.first ?? "") - .resolvingSymlinksInPath() - .deletingLastPathComponent() // MacOS - .deletingLastPathComponent() // Contents - let proc = Process() - proc.launchPath = "/usr/bin/open" - proc.arguments = ["-n", appURL.path] - proc.standardOutput = Pipe() - proc.standardError = Pipe() - try proc.run() - try? await Task.sleep(nanoseconds: 100_000_000) - } -} -// swiftlint:enable type_body_length - -enum CLIError: Error { case help, version } - -struct CLITimeoutError: Error, CustomStringConvertible { - let seconds: TimeInterval - var description: String { - let rounded = Int(max(1, seconds.rounded(.toNearestOrEven))) - return "timed out after \(rounded)s" - } -} - -extension [String] { - mutating func popFirst() -> String? { - guard let first else { return nil } - self = Array(self.dropFirst()) - return first - } -} diff --git a/apps/macos/Sources/ClawdisCLI/FileHandle+SafeRead.swift b/apps/macos/Sources/ClawdisCLI/FileHandle+SafeRead.swift deleted file mode 100644 index eb2c3d26a..000000000 --- a/apps/macos/Sources/ClawdisCLI/FileHandle+SafeRead.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation - -extension FileHandle { - /// Reads until EOF using the throwing FileHandle API and returns empty `Data` on failure. - /// - /// Important: Avoid legacy, non-throwing FileHandle read APIs (e.g. `readDataToEndOfFile()` and - /// `availableData`). They can raise Objective-C exceptions when the handle is closed/invalid, which - /// will abort the process. - func readToEndSafely() -> Data { - do { - return try self.readToEnd() ?? Data() - } catch { - return Data() - } - } - - /// Reads up to `count` bytes using the throwing FileHandle API and returns empty `Data` on failure/EOF. - func readSafely(upToCount count: Int) -> Data { - do { - return try self.read(upToCount: count) ?? Data() - } catch { - return Data() - } - } -} diff --git a/apps/macos/Tests/ClawdisIPCTests/ControlRequestHandlerTests.swift b/apps/macos/Tests/ClawdisIPCTests/ControlRequestHandlerTests.swift deleted file mode 100644 index 280c08a52..000000000 --- a/apps/macos/Tests/ClawdisIPCTests/ControlRequestHandlerTests.swift +++ /dev/null @@ -1,173 +0,0 @@ -import ClawdisIPC -import Foundation -import Testing -@testable import Clawdis - -@Suite(.serialized) -struct ControlRequestHandlerTests { - private static func withDefaultOverride( - _ key: String, - value: Any?, - operation: () async throws -> T) async rethrows -> T - { - let defaults = UserDefaults.standard - let previous = defaults.object(forKey: key) - if let value { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - defer { - if let previous { - defaults.set(previous, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - return try await operation() - } - - @Test - func statusReturnsReadyWhenNotPaused() async throws { - let defaults = UserDefaults.standard - let previous = defaults.object(forKey: pauseDefaultsKey) - defaults.set(false, forKey: pauseDefaultsKey) - defer { - if let previous { - defaults.set(previous, forKey: pauseDefaultsKey) - } else { - defaults.removeObject(forKey: pauseDefaultsKey) - } - } - - let res = try await ControlRequestHandler.process(request: .status) - #expect(res.ok == true) - #expect(res.message == "ready") - } - - @Test - func statusReturnsPausedWhenPaused() async throws { - let defaults = UserDefaults.standard - let previous = defaults.object(forKey: pauseDefaultsKey) - defaults.set(true, forKey: pauseDefaultsKey) - defer { - if let previous { - defaults.set(previous, forKey: pauseDefaultsKey) - } else { - defaults.removeObject(forKey: pauseDefaultsKey) - } - } - - let res = try await ControlRequestHandler.process(request: .status) - #expect(res.ok == false) - #expect(res.message == "clawdis paused") - } - - @Test - func nonStatusRequestsShortCircuitWhenPaused() async throws { - let defaults = UserDefaults.standard - let previous = defaults.object(forKey: pauseDefaultsKey) - defaults.set(true, forKey: pauseDefaultsKey) - defer { - if let previous { - defaults.set(previous, forKey: pauseDefaultsKey) - } else { - defaults.removeObject(forKey: pauseDefaultsKey) - } - } - - let res = try await ControlRequestHandler.process(request: .rpcStatus) - #expect(res.ok == false) - #expect(res.message == "clawdis paused") - } - - @Test - func agentRejectsEmptyMessage() async throws { - let res = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) { - try await ControlRequestHandler.process(request: .agent( - message: " ", - thinking: nil, - session: nil, - deliver: false, - to: nil)) - } - #expect(res.ok == false) - #expect(res.message == "message empty") - } - - @Test - func runShellEchoReturnsPayload() async throws { - let res = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) { - try await ControlRequestHandler.process(request: .runShell( - command: ["echo", "hello"], - cwd: nil, - env: nil, - timeoutSec: nil, - needsScreenRecording: false)) - } - #expect(res.ok == true) - #expect(String(data: res.payload ?? Data(), encoding: .utf8) == "hello\n") - } - - @Test - func cameraRequestsReturnDisabledWhenCameraDisabled() async throws { - let snap = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) { - try await Self.withDefaultOverride(cameraEnabledKey, value: false) { - try await ControlRequestHandler.process(request: .cameraSnap( - facing: nil, - maxWidth: nil, - quality: nil, - outPath: nil)) - } - } - #expect(snap.ok == false) - #expect(snap.message == "Camera disabled by user") - - let clip = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) { - try await Self.withDefaultOverride(cameraEnabledKey, value: false) { - try await ControlRequestHandler.process(request: .cameraClip( - facing: nil, - durationMs: nil, - includeAudio: true, - outPath: nil)) - } - } - #expect(clip.ok == false) - #expect(clip.message == "Camera disabled by user") - } - - @Test - func canvasRequestsReturnDisabledWhenCanvasDisabled() async throws { - let show = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) { - try await Self.withDefaultOverride(canvasEnabledKey, value: false) { - try await ControlRequestHandler.process(request: .canvasPresent(session: "s", path: nil, placement: nil)) - } - } - #expect(show.ok == false) - #expect(show.message == "Canvas disabled by user") - - let eval = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) { - try await Self.withDefaultOverride(canvasEnabledKey, value: false) { - try await ControlRequestHandler.process(request: .canvasEval(session: "s", javaScript: "1+1")) - } - } - #expect(eval.ok == false) - #expect(eval.message == "Canvas disabled by user") - - let snap = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) { - try await Self.withDefaultOverride(canvasEnabledKey, value: false) { - try await ControlRequestHandler.process(request: .canvasSnapshot(session: "s", outPath: nil)) - } - } - #expect(snap.ok == false) - #expect(snap.message == "Canvas disabled by user") - - let a2ui = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) { - try await Self.withDefaultOverride(canvasEnabledKey, value: false) { - try await ControlRequestHandler.process(request: .canvasA2UI(session: "s", command: .reset, jsonl: nil)) - } - } - #expect(a2ui.ok == false) - #expect(a2ui.message == "Canvas disabled by user") - } -} diff --git a/apps/macos/Tests/ClawdisIPCTests/ControlSocketServerTests.swift b/apps/macos/Tests/ClawdisIPCTests/ControlSocketServerTests.swift deleted file mode 100644 index bf5292b38..000000000 --- a/apps/macos/Tests/ClawdisIPCTests/ControlSocketServerTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation -import Testing -@testable import Clawdis - -@Suite struct ControlSocketServerTests { - private static func codesignTeamIdentifier(executablePath: String) -> String? { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") - proc.arguments = ["-dv", "--verbose=4", executablePath] - proc.standardOutput = Pipe() - let stderr = Pipe() - proc.standardError = stderr - - do { - try proc.run() - proc.waitUntilExit() - } catch { - return nil - } - - guard proc.terminationStatus == 0 else { - return nil - } - - let data = stderr.fileHandleForReading.readToEndSafely() - guard let text = String(data: data, encoding: .utf8) else { return nil } - for line in text.split(separator: "\n") { - if line.hasPrefix("TeamIdentifier=") { - let raw = String(line.dropFirst("TeamIdentifier=".count)) - .trimmingCharacters(in: .whitespacesAndNewlines) - return raw == "not set" ? nil : raw - } - } - return nil - } - - @Test func teamIdentifierLookupMatchesCodesign() async { - let pid = getpid() - let execPath = CommandLine.arguments.first ?? "" - - let expected = Self.codesignTeamIdentifier(executablePath: execPath) - let actual = ControlSocketServer._testTeamIdentifier(pid: pid) - - if let expected, !expected.isEmpty { - #expect(actual == expected) - } else { - #expect(actual == nil || actual?.isEmpty == true) - } - } -} diff --git a/apps/macos/Tests/ClawdisIPCTests/NodeListTests.swift b/apps/macos/Tests/ClawdisIPCTests/NodeListTests.swift deleted file mode 100644 index d48536dc9..000000000 --- a/apps/macos/Tests/ClawdisIPCTests/NodeListTests.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Testing -@testable import Clawdis - -@Suite struct NodeListTests { - @Test func nodeListMapsGatewayPayloadIncludingHardwareAndCaps() async { - let payload = ControlRequestHandler.GatewayNodeListPayload( - ts: 123, - nodes: [ - ControlRequestHandler.GatewayNodeListPayload.Node( - nodeId: "n1", - displayName: "Node", - platform: "iOS", - version: "1.0", - deviceFamily: "iPad", - modelIdentifier: "iPad14,5", - remoteIp: "192.168.0.88", - connected: true, - paired: true, - caps: ["canvas", "camera"]), - ControlRequestHandler.GatewayNodeListPayload.Node( - nodeId: "n2", - displayName: "Offline", - platform: "iOS", - version: "1.0", - deviceFamily: "iPhone", - modelIdentifier: "iPhone14,2", - remoteIp: nil, - connected: false, - paired: true, - caps: nil), - ]) - - let res = ControlRequestHandler.buildNodeListResult(payload: payload) - - #expect(res.ts == 123) - #expect(res.pairedNodeIds.sorted() == ["n1", "n2"]) - #expect(res.connectedNodeIds == ["n1"]) - - let node = res.nodes.first { $0.nodeId == "n1" } - #expect(node?.remoteAddress == "192.168.0.88") - #expect(node?.deviceFamily == "iPad") - #expect(node?.modelIdentifier == "iPad14,5") - #expect(node?.capabilities?.sorted() == ["camera", "canvas"]) - } -} diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift index 7f7973eff..cecea94d0 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift @@ -67,6 +67,7 @@ public struct BridgeHello: Codable, Sendable { public let modelIdentifier: String? public let caps: [String]? public let commands: [String]? + public let permissions: [String: Bool]? public init( type: String = "hello", @@ -78,7 +79,8 @@ public struct BridgeHello: Codable, Sendable { deviceFamily: String? = nil, modelIdentifier: String? = nil, caps: [String]? = nil, - commands: [String]? = nil) + commands: [String]? = nil, + permissions: [String: Bool]? = nil) { self.type = type self.nodeId = nodeId @@ -90,6 +92,7 @@ public struct BridgeHello: Codable, Sendable { self.modelIdentifier = modelIdentifier self.caps = caps self.commands = commands + self.permissions = permissions } } @@ -113,6 +116,7 @@ public struct BridgePairRequest: Codable, Sendable { public let modelIdentifier: String? public let caps: [String]? public let commands: [String]? + public let permissions: [String: Bool]? public let remoteAddress: String? public let silent: Bool? @@ -126,6 +130,7 @@ public struct BridgePairRequest: Codable, Sendable { modelIdentifier: String? = nil, caps: [String]? = nil, commands: [String]? = nil, + permissions: [String: Bool]? = nil, remoteAddress: String? = nil, silent: Bool? = nil) { @@ -138,6 +143,7 @@ public struct BridgePairRequest: Codable, Sendable { self.modelIdentifier = modelIdentifier self.caps = caps self.commands = commands + self.permissions = permissions self.remoteAddress = remoteAddress self.silent = silent } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/SystemCommands.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/SystemCommands.swift new file mode 100644 index 000000000..1e10986fd --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/SystemCommands.swift @@ -0,0 +1,62 @@ +import Foundation + +public enum ClawdisSystemCommand: String, Codable, Sendable { + case run = "system.run" + case notify = "system.notify" +} + +public enum ClawdisNotificationPriority: String, Codable, Sendable { + case passive + case active + case timeSensitive +} + +public enum ClawdisNotificationDelivery: String, Codable, Sendable { + case system + case overlay + case auto +} + +public struct ClawdisSystemRunParams: Codable, Sendable, Equatable { + public var command: [String] + public var cwd: String? + public var env: [String: String]? + public var timeoutMs: Int? + public var needsScreenRecording: Bool? + + public init( + command: [String], + cwd: String? = nil, + env: [String: String]? = nil, + timeoutMs: Int? = nil, + needsScreenRecording: Bool? = nil) + { + self.command = command + self.cwd = cwd + self.env = env + self.timeoutMs = timeoutMs + self.needsScreenRecording = needsScreenRecording + } +} + +public struct ClawdisSystemNotifyParams: Codable, Sendable, Equatable { + public var title: String + public var body: String + public var sound: String? + public var priority: ClawdisNotificationPriority? + public var delivery: ClawdisNotificationDelivery? + + public init( + title: String, + body: String, + sound: String? = nil, + priority: ClawdisNotificationPriority? = nil, + delivery: ClawdisNotificationDelivery? = nil) + { + self.title = title + self.body = body + self.sound = sound + self.priority = priority + self.delivery = delivery + } +} diff --git a/docs/AGENTS.default.md b/docs/AGENTS.default.md index ffb1332e8..00a0edf1b 100644 --- a/docs/AGENTS.default.md +++ b/docs/AGENTS.default.md @@ -63,7 +63,7 @@ git commit -m "Add Clawd workspace" ## What Clawdis Does - Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run tools via the host Mac. -- macOS app manages permissions (screen recording, notifications, microphone) and exposes a CLI helper `clawdis-mac` for scripts. +- macOS app manages permissions (screen recording, notifications, microphone) and exposes the `clawdis` CLI via its bundled binary. - Direct chats collapse into the shared `main` session by default; groups stay isolated as `group:`; heartbeats keep background tasks alive. ## Core Tools (enable in Settings → Tools) @@ -91,7 +91,7 @@ git commit -m "Add Clawd workspace" - **Google Calendar MCP** (`google-calendar`) — List, create, and update events. ## Usage Notes -- Prefer the `clawdis-mac` CLI for scripting; mac app handles permissions. +- Prefer the `clawdis` CLI for scripting; mac app handles permissions. - Run installs from the Tools tab; it hides the button if a tool is already present. - For MCPs, mcporter writes to the home-scope config; re-run installs if you rotate tokens. - Keep heartbeats enabled so the assistant can schedule reminders, monitor inboxes, and trigger camera captures. diff --git a/docs/camera.md b/docs/camera.md index 8e9f499b4..302e06d33 100644 --- a/docs/camera.md +++ b/docs/camera.md @@ -11,7 +11,7 @@ Clawdis supports **camera capture** for agent workflows: - **iOS node** (paired via Gateway): capture a **photo** (`jpg`) or **short video clip** (`mp4`, with optional audio) via `node.invoke`. - **Android node** (paired via Gateway): capture a **photo** (`jpg`) or **short video clip** (`mp4`, with optional audio) via `node.invoke`. -- **macOS app** (local control socket): capture a **photo** (`jpg`) or **short video clip** (`mp4`, with optional audio) via `clawdis-mac`. +- **macOS app** (node via Gateway): capture a **photo** (`jpg`) or **short video clip** (`mp4`, with optional audio) via `node.invoke`. All camera access is gated behind **user-controlled settings**. @@ -100,22 +100,22 @@ The macOS companion app exposes a checkbox: - Default: **off** - When off: camera requests return “Camera disabled by user”. -### CLI helper (local control socket) +### CLI helper (node invoke) -The `clawdis-mac` helper talks to the running menu bar app over the local control socket. +Use the main `clawdis` CLI to invoke camera commands on the macOS node. Examples: ```bash -clawdis-mac camera snap # prints MEDIA: -clawdis-mac camera snap --max-width 1280 -clawdis-mac camera clip --duration 10s # prints MEDIA: -clawdis-mac camera clip --duration-ms 3000 # prints MEDIA: (legacy flag) -clawdis-mac camera clip --no-audio +clawdis nodes camera snap --node # prints MEDIA: +clawdis nodes camera snap --node --max-width 1280 +clawdis nodes camera clip --node --duration 10s # prints MEDIA: +clawdis nodes camera clip --node --duration-ms 3000 # prints MEDIA: (legacy flag) +clawdis nodes camera clip --node --no-audio ``` Notes: -- `clawdis-mac camera snap` defaults to `maxWidth=1600` unless overridden. +- `clawdis nodes camera snap` defaults to `maxWidth=1600` unless overridden. ## Safety + practical limits @@ -127,7 +127,7 @@ Notes: For *screen* video (not camera), use the macOS companion: ```bash -clawdis-mac screen record --duration 10s --fps 15 # prints MEDIA: +clawdis nodes screen record --node --duration 10s --fps 15 # prints MEDIA: ``` Notes: diff --git a/docs/clawdis-mac.md b/docs/clawdis-mac.md index 34eb06e7a..c3f54f412 100644 --- a/docs/clawdis-mac.md +++ b/docs/clawdis-mac.md @@ -1,95 +1,57 @@ --- -summary: "Spec for the Clawdis macOS companion menu bar app and local broker (control socket + PeekabooBridge)" +summary: "Spec for the Clawdis macOS companion menu bar app (gateway + node broker)" read_when: - Implementing macOS app features - - Touching broker/CLI bridging + - Changing gateway lifecycle or node bridging on macOS --- -# Clawdis macOS Companion (menu bar + local broker) +# Clawdis macOS Companion (menu bar + gateway broker) -Author: steipete · Status: draft spec · Date: 2025-12-05 +Author: steipete · Status: draft spec · Date: 2025-12-20 ## Purpose - Single macOS menu-bar app named **Clawdis** that: - Shows native notifications for Clawdis/clawdis events. - Owns TCC prompts (Notifications, Accessibility, Screen Recording, Automation/AppleScript, Microphone, Speech Recognition). - - Brokers privileged actions via local IPC: - - Clawdis control socket (app-specific actions like notify/run) - - PeekabooBridge socket (`bridge.sock`) for UI automation brokering (consumed by `peekaboo`; see `docs/mac/peekaboo.md`) - - Provides a tiny CLI (`clawdis-mac`) that talks to the app; Node/TS shells out to it. -- Replace the separate notifier helper pattern (Oracle) with a built-in notifier. -- Offer a first-run experience similar to VibeTunnel’s onboarding (permissions + CLI install). + - Runs (or connects to) the **Gateway** and exposes itself as a **node** so agents can reach macOS‑only features. + - Hosts **PeekabooBridge** for UI automation (consumed by `peekaboo`; see `docs/mac/peekaboo.md`). + - Installs a single CLI (`clawdis`) by symlinking the bundled binary. ## High-level design - SwiftPM package in `apps/macos/` (macOS 15+, Swift 6). - Targets: - - `ClawdisIPC` (shared Codable types + helpers for app-specific commands). - - `Clawdis` (LSUIElement MenuBarExtra app; hosts control socket + optional PeekabooBridgeHost). - - `ClawdisCLI` (`clawdis-mac`; prints text by default, `--json` for scripts). + - `ClawdisIPC` (shared Codable types + helpers for app‑internal actions). + - `Clawdis` (LSUIElement MenuBarExtra app; hosts Gateway + node bridge + PeekabooBridgeHost). - Bundle ID: `com.steipete.clawdis`. -- The CLI lives in the app bundle `Contents/Helpers/clawdis-mac`; dev symlink `bin/clawdis-mac` points there. -- Node/TS layer calls the CLI; no direct privileged API calls from Node. +- Bundled runtime binaries live under `Contents/Resources/Relay/`: + - `clawdis-gateway` (bun‑compiled Gateway) + - `clawdis` (bun‑compiled CLI) +- The app symlinks `clawdis` into `/usr/local/bin` and `/opt/homebrew/bin`. -Note: `docs/mac/xpc.md` describes an aspirational long-term Mach/XPC architecture. The current direction for UI automation is PeekabooBridge (socket-based). +## Gateway + node bridge +- The mac app runs the Gateway in **local** mode (unless configured remote). +- The mac app connects to the bridge as a **node** and advertises capabilities/commands. +- Agent‑facing actions are exposed via `node.invoke` (no local control socket). -## IPC contract (ClawdisIPC) -- Codable enums; small payloads (<1 MB enforced in listener): +### Node commands (mac) +- Canvas: `canvas.present|navigate|eval|snapshot|a2ui.*` +- Camera: `camera.snap|camera.clip` +- Screen: `screen.record` +- System: `system.run` (shell) and `system.notify` -``` -enum Capability { notifications, accessibility, screenRecording, appleScript, microphone, speechRecognition } -enum Request { - notify(title, body, sound?) - ensurePermissions([Capability], interactive: Bool) - runShell(command:[String], cwd?, env?, timeoutSec?, needsScreenRecording: Bool) - status -} -struct Response { ok: Bool; message?: String; payload?: Data } -``` -- The control-socket server rejects oversize/unknown cases and validates the caller by code signature TeamID (with a `DEBUG`-only same-UID escape hatch controlled by `CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1`). +### Permission advertising +- Nodes include a `permissions` map in hello/pairing. +- The Gateway surfaces it via `node.list` / `node.describe` so agents can decide what to run. -UI automation is not part of `ClawdisIPC.Request`: -- UI automation is handled via the separate PeekabooBridge socket and is surfaced by the `peekaboo` CLI (see `docs/mac/peekaboo.md`). +## CLI (`clawdis`) +- The **only** CLI is `clawdis` (TS/bun). There is no `clawdis-mac` helper. +- For mac‑specific actions, the CLI uses `node.invoke`: + - `clawdis canvas present|navigate|eval|snapshot|a2ui push|a2ui reset` + - `clawdis nodes run --node -- ` + - `clawdis nodes notify --node --title ...` -## App UX (Clawdis) -- MenuBarExtra icon only (LSUIElement; no Dock). -- Menu items: Status, Permissions…, **Pause Clawdis** toggle (temporarily deny privileged actions/notifications without quitting), Quit. -- Settings window (Trimmy-style tabs): -- General: launch at login toggle and debug/visibility toggles (no per-user default sound; pass sounds per notification via CLI). - - Permissions: live status + “Request” buttons for Notifications/Accessibility/Screen Recording; links to System Settings. - - Debug (when enabled): PID/log links, restart/reveal app shortcuts, manual test notification. - - About: version, links, license. -- Pause behavior: matches Trimmy’s “Auto Trim” toggle. When paused, the broker returns `ok=false, message="clawdis paused"` for actions that would touch TCC. State is persisted (UserDefaults) and surfaced in menu and status view. -- Onboarding (VibeTunnel-inspired): Welcome → What it does → Install CLI (shows `ln -s .../clawdis-mac /usr/local/bin`) → Permissions checklist with live status → Test notification → Done. Re-show when `welcomeVersion` bumps or CLI/app version mismatch. - -## Built-in services -- NotificationManager: UNUserNotificationCenter primary; AppleScript `display notification` fallback; respects the `--sound` value on each request. -- PermissionManager: checks/requests Notifications, Accessibility (AX), Screen Recording (capture probe); publishes changes for UI. -- UI automation + capture: provided by **PeekabooBridgeHost** when enabled (see `docs/mac/peekaboo.md`). -- ShellExecutor: executes `Process` with timeout; rejects when `needsScreenRecording` and permission missing; returns stdout/stderr in payload. -- ControlSocketServer actor: routes Request → managers; logs via OSLog. - -## CLI (`clawdis-mac`) -- Subcommands (text by default; `--json` for machine output; non-zero exit on failure): - - `notify --title --body [--sound] [--priority passive|active|timeSensitive] [--delivery system|overlay|auto]` - - `ensure-permissions --cap accessibility --cap screenRecording [--interactive]` - - UI automation + capture: use `peekaboo …` (Clawdis hosts PeekabooBridge; see `docs/mac/peekaboo.md`) - - `run -- cmd args... [--cwd] [--env KEY=VAL] [--timeout 30] [--needs-screen-recording]` - - `status` - - Nodes (bridge-connected companions): - - `node list` — lists paired + currently connected nodes, including advertised capabilities (e.g. `canvas`, `camera`) and hardware identifiers (`deviceFamily`, `modelIdentifier`). - - `node invoke --node --command [--params-json ]` -- Sounds: supply any macOS alert name with `--sound` per notification; omit the flag to use the system default. There is no longer a persisted “default sound” in the app UI. -- Priority: `timeSensitive` is best-effort and falls back to `active` unless the app is signed with the Time Sensitive Notifications entitlement. -- Delivery: `overlay` and `auto` show an in-app toast panel (bypasses Notification Center/Focus). -- Internals: - - For app-specific commands (`notify`, `ensure-permissions`, `run`, `status`): build `ClawdisIPC.Request`, send over the control socket. - - UI automation is intentionally not exposed via `clawdis-mac`; it lives behind PeekabooBridge and is surfaced by the `peekaboo` CLI. - -## Integration with clawdis/Clawdis (Node/TS) -- Add helper module that shells to `clawdis-mac`: - - Prefer `ensure-permissions` before actions that need TCC. - - Use `notify` for desktop toasts; fall back to JS notifier only if CLI missing or platform ≠ macOS. - - Use `run` for tasks requiring privileged UI context (screen-recorded terminal runs, etc.). - - For UI automation, shell out to `peekaboo …` (text by default; add `--json` for structured output) and rely on PeekabooBridge host selection (Peekaboo.app → Clawdis.app → local). +## Onboarding +- Install CLI (symlink) → Permissions checklist → Test notification → Done. +- Remote mode skips local gateway/CLI steps. ## Deep links (URL scheme) @@ -127,24 +89,12 @@ Notes: - In local mode, Clawdis will start the local Gateway if needed before issuing the request. - In remote mode, Clawdis will use the configured remote tunnel/endpoint. -## Permissions strategy -- All TCC prompts originate from the app bundle; CLI and Node stay headless. -- Permission checks are idempotent; onboarding surfaces missing grants and provides one-click request buttons. - ## Build & dev workflow (native) - `cd native && swift build` (debug) / `swift build -c release`. - Run app for dev: `swift run Clawdis` (or Xcode scheme). -- Package app + helper: `swift build -c release && swift package --allow-writing-to-directory ../dist` (tbd exact script). -- Tests: add Swift Testing suites under `apps/macos/Tests` (especially IPC round-trips and permission probing fakes). - -## Icon pipeline -- Source asset lives at `apps/macos/Icon.icon` (glass .icon bundle). -- Regenerate the bundled icns via `scripts/build_icon.sh` (uses ictool/icontool + sips), which outputs to - `apps/macos/Sources/Clawdis/Resources/Clawdis.icns` by default. Override `DEST_ICNS` to change the target. - The script also writes intermediate renders to `apps/macos/build/icon/`. +- Package app + CLI: `scripts/package-mac-app.sh` (builds bun CLI + gateway). +- Tests: add Swift Testing suites under `apps/macos/Tests`. ## Open questions / decisions -- Where to place the dev symlink `bin/clawdis-mac` (repo root vs. `apps/macos/bin`)? -- Should `runShell` support streaming stdout/stderr (IPC with AsyncSequence) or just buffered? (Start buffered; streaming later.) -- Icon: reuse Clawdis lobster or new mac-specific glyph? -- Sparkle updates: bundled via Sparkle; release builds point at `https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml` and enable auto-checks, while debug builds leave the feed blank and disable checks. +- Should `system.run` support streaming stdout/stderr or keep buffered responses only? +- Should we allow node‑side permission prompts, or always require explicit app UI action? diff --git a/docs/configuration.md b/docs/configuration.md index 2a04fc5eb..3379fadd8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -154,6 +154,27 @@ Defaults: } ``` +### `gateway` (Gateway server mode + bind) + +Use `gateway.mode` to explicitly declare whether this machine should run the Gateway. + +Defaults: +- mode: **unset** (treated as “do not auto-start”) +- bind: `loopback` + +```json5 +{ + gateway: { + mode: "local", // or "remote" + bind: "loopback", + // controlUi: { enabled: true } + } +} +``` + +Notes: +- `clawdis gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). + ### `canvasHost` (LAN/tailnet Canvas file server + live reload) The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can simply `canvas.navigate` to it. diff --git a/docs/ios/spec.md b/docs/ios/spec.md index 3f00da356..bdce1e1a9 100644 --- a/docs/ios/spec.md +++ b/docs/ios/spec.md @@ -31,7 +31,7 @@ Non-goals (v1): ## Current repo reality (constraints we respect) - The Gateway WebSocket server binds to `127.0.0.1:18789` (`src/gateway/server.ts`) with an optional `CLAWDIS_GATEWAY_TOKEN`. - The Gateway exposes a LAN/tailnet Canvas file server (`canvasHost`) by default so nodes can `canvas.navigate` to `http://:/` and auto-reload when files change (`docs/configuration.md`). -- macOS “Canvas” exists today, but is **mac-only** and controlled via mac app IPC (`clawdis-mac canvas ...`) rather than the Gateway protocol (`docs/mac/canvas.md`). +- macOS “Canvas” is controlled via the Gateway node protocol (`canvas.*`), matching iOS/Android (`docs/mac/canvas.md`). - Voice wake forwards via `GatewayChannel` to Gateway `agent` (mac app: `VoiceWakeForwarder` → `GatewayConnection.sendAgent`). ## Recommended topology (B): Gateway-owned Bridge + loopback Gateway diff --git a/docs/mac/bun.md b/docs/mac/bun.md index d8d41775f..b4bff0a93 100644 --- a/docs/mac/bun.md +++ b/docs/mac/bun.md @@ -16,6 +16,8 @@ App bundle layout: - `Clawdis.app/Contents/Resources/Relay/clawdis-gateway` - bun `--compile` executable built from `dist/macos/gateway-daemon.js` +- `Clawdis.app/Contents/Resources/Relay/clawdis` + - bun `--compile` CLI executable built from `dist/index.js` - `Clawdis.app/Contents/Resources/Relay/package.json` - tiny “Pi compatibility” file (see below) - `Clawdis.app/Contents/Resources/Relay/theme/` diff --git a/docs/mac/canvas.md b/docs/mac/canvas.md index 5dcd6c967..367d894de 100644 --- a/docs/mac/canvas.md +++ b/docs/mac/canvas.md @@ -77,9 +77,9 @@ Implementation notes: - Use an `NSTrackingArea` to fade the chrome in/out on `mouseEntered/mouseExited`. - Optionally show close/drag affordances only while hovered. -## Agent API surface (proposed) +## Agent API surface (current) -Expose Canvas via the existing `clawdis-mac` → control socket → app routing so the agent can: +Canvas is exposed via the Gateway **node bridge**, so the agent can: - Show/hide the panel. - Navigate to a path (relative to the session root). - Evaluate JavaScript and optionally return results. @@ -94,21 +94,21 @@ Related: ## Agent commands (current) -`clawdis-mac` exposes Canvas via the control socket. For agent use, prefer `--json` so you can read the structured `CanvasShowResult` (including `status`). +Use the main `clawdis` CLI; it invokes canvas commands via `node.invoke`. -- `clawdis-mac canvas present [--session ] [--target <...>] [--x/--y/--width/--height]` +- `clawdis canvas present [--node ] [--target <...>] [--x/--y/--width/--height]` - Local targets map into the session directory via the custom scheme (directory targets resolve `index.html|index.htm`). - If `/` has no index file, Canvas shows the built-in A2UI shell and returns `status: "a2uiShell"`. -- `clawdis-mac canvas hide [--session ]` -- `clawdis-mac canvas eval --js [--session ]` -- `clawdis-mac canvas snapshot [--out ] [--session ]` +- `clawdis canvas hide [--node ]` +- `clawdis canvas eval --js [--node ]` +- `clawdis canvas snapshot [--node ]` ### Canvas A2UI Canvas includes a built-in **A2UI v0.8** renderer (Lit-based). The agent can drive it with JSONL **server→client protocol messages** (one JSON object per line): -- `clawdis-mac canvas a2ui push --jsonl [--session ]` -- `clawdis-mac canvas a2ui reset [--session ]` +- `clawdis canvas a2ui push --jsonl [--node ]` +- `clawdis canvas a2ui reset [--node ]` `push` expects a JSONL file where **each line is a single JSON object** (parsed and forwarded to the in-page A2UI renderer). @@ -120,7 +120,7 @@ cat > /tmp/a2ui-v0.8.jsonl <<'EOF' {"beginRendering":{"surfaceId":"main","root":"root"}} EOF -clawdis-mac canvas a2ui push --jsonl /tmp/a2ui-v0.8.jsonl --session main +clawdis canvas a2ui push --jsonl /tmp/a2ui-v0.8.jsonl --node ``` Notes: diff --git a/docs/mac/child-process.md b/docs/mac/child-process.md index 56502676c..09c95e648 100644 --- a/docs/mac/child-process.md +++ b/docs/mac/child-process.md @@ -23,13 +23,11 @@ Run the Node-based Clawdis/clawdis gateway as a direct child of the LSUIElement - **TCC:** behaviorally, child processes often inherit the parent app’s “responsible process” for TCC, but this is *not a contract*. Continue to route all protected actions through the Swift app/broker so prompts stay tied to the signed app bundle. ## TCC guardrails (must keep) -- Screen Recording, Accessibility, mic, and speech prompts must originate from the signed Swift app/broker. The Node child should never call these APIs directly; use the CLI broker (`clawdis-mac`) for: - - `ensure-permissions` - - `ui screenshot` (via PeekabooBridge host) - - other `ui …` automation (see/click/type/scroll/wait) when implemented - - mic/speech permission checks - - notifications - - shell runs that need `needs-screen-recording` +- Screen Recording, Accessibility, mic, and speech prompts must originate from the signed Swift app/broker. The Node child should never call these APIs directly; route through the app’s node commands (via Gateway `node.invoke`) for: + - `system.notify` + - `system.run` (including `needsScreenRecording`) + - `screen.record` / `camera.*` + - PeekabooBridge UI automation (`peekaboo …`) - Usage strings (`NSMicrophoneUsageDescription`, `NSSpeechRecognitionUsageDescription`, etc.) stay in the app target’s Info.plist; a bare Node binary has none and would fail. - If you ever embed Node that *must* touch TCC, wrap that call in a tiny signed helper target inside the app bundle and have Node exec that helper instead of calling the API directly. @@ -69,6 +67,6 @@ Run the Node-based Clawdis/clawdis gateway as a direct child of the LSUIElement - Do we want a tiny signed helper for rare TCC actions that cannot be brokered via the Swift app/broker? ## Decision snapshot (current recommendation) -- Keep all TCC surfaces in the Swift app/broker (control socket + PeekabooBridgeHost). +- Keep all TCC surfaces in the Swift app/broker (node commands + PeekabooBridgeHost). - Implement `GatewayProcessManager` with Swift Subprocess to start/stop the gateway on the “Clawdis Active” toggle. -- Maintain the launchd path as a fallback for uptime/login persistence until child-mode proves stable. +- Maintain the launchd path as a fallback for uptime/login persistence until child-mode proves stable. diff --git a/docs/mac/peekaboo.md b/docs/mac/peekaboo.md index fe865e81b..9a68bbddc 100644 --- a/docs/mac/peekaboo.md +++ b/docs/mac/peekaboo.md @@ -67,7 +67,7 @@ What Clawdis should *not* embed: - **XPC**: don’t reintroduce helper targets; use the bridge. ## IPC / CLI surface -### No `clawdis-mac ui …` +### No `clawdis ui …` We avoid a parallel “Clawdis UI automation CLI”. Instead: - `peekaboo` is the user/agent-facing CLI surface for automation and capture. - Clawdis.app can host PeekabooBridge as a **thin TCC broker** so Peekaboo can piggyback on Clawdis permissions when Peekaboo.app isn’t running. diff --git a/docs/mac/remote.md b/docs/mac/remote.md index ea1182c43..4709fa144 100644 --- a/docs/mac/remote.md +++ b/docs/mac/remote.md @@ -7,7 +7,7 @@ read_when: Updated: 2025-12-08 -This flow lets the macOS app act as a full remote control for a Clawdis gateway running on another host (e.g. a Mac Studio). All features—health checks, permissions bootstrapping via the helper CLI, Voice Wake forwarding, and Web Chat—reuse the same remote SSH configuration from *Settings → General*. +This flow lets the macOS app act as a full remote control for a Clawdis gateway running on another host (e.g. a Mac Studio). All features—health checks, Voice Wake forwarding, and Web Chat—reuse the same remote SSH configuration from *Settings → General*. ## Modes - **Local (this Mac)**: Everything runs on the laptop. No SSH involved. @@ -15,7 +15,7 @@ This flow lets the macOS app act as a full remote control for a Clawdis gateway ## Prereqs on the remote host 1) Install Node + pnpm and build/install the Clawdis CLI (`pnpm install && pnpm build && pnpm link --global`). -2) Ensure `clawdis` is on PATH for non-interactive shells. If you prefer, symlink `clawdis-mac` too so TCC-capable actions can run remotely when needed. +2) Ensure `clawdis` is on PATH for non-interactive shells (symlink into `/usr/local/bin` or `/opt/homebrew/bin` if needed). 3) Open SSH with key auth. We recommend **Tailscale** IPs for stable reachability off-LAN. ## macOS app setup @@ -34,7 +34,7 @@ This flow lets the macOS app act as a full remote control for a Clawdis gateway ## Permissions - The remote host needs the same TCC approvals as local (Automation, Accessibility, Screen Recording, Microphone, Speech Recognition, Notifications). Run onboarding on that machine to grant them once. -- When remote commands need local TCC (e.g., screenshots on the remote Mac), ensure `clawdis-mac` is installed there so the helper can request/hold those permissions. +- Nodes advertise their permission state via `node.list` / `node.describe` so agents know what’s available. ## WhatsApp login flow (remote) - Run `clawdis login --verbose` **on the remote host**. Scan the QR with WhatsApp on your phone. @@ -47,10 +47,10 @@ This flow lets the macOS app act as a full remote control for a Clawdis gateway - **Voice Wake**: trigger phrases are forwarded automatically in remote mode; no separate forwarder is needed. ## Notification sounds -Pick sounds per notification from scripts with the helper CLI, e.g.: +Pick sounds per notification from scripts with `clawdis` and `node.invoke`, e.g.: ```bash -clawdis-mac notify --title "Ping" --body "Remote gateway ready" --sound Glass +clawdis nodes notify --node --title "Ping" --body "Remote gateway ready" --sound Glass ``` There is no global “default sound” toggle in the app anymore; callers choose a sound (or none) per request. diff --git a/docs/mac/xpc.md b/docs/mac/xpc.md index 0d7313d16..a0b14135e 100644 --- a/docs/mac/xpc.md +++ b/docs/mac/xpc.md @@ -1,21 +1,21 @@ --- -summary: "macOS IPC architecture for Clawdis app, CLI helper, and gateway bridge (control socket + XPC + PeekabooBridge)" +summary: "macOS IPC architecture for Clawdis app, gateway node bridge, and PeekabooBridge" read_when: - Editing IPC contracts or menu bar app IPC --- # Clawdis macOS IPC architecture (Dec 2025) -Note: the current implementation primarily uses a local UNIX-domain control socket (`controlSocketPath`) between `clawdis-mac` and the app. This doc captures the intended long-term Mach/XPC direction and the security constraints, and also documents the separate PeekabooBridge socket used for UI automation. +**Current model:** there is **no local control socket** and no `clawdis-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge. ## Goals - Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript). -- A small surface for automation: the `clawdis-mac` CLI and the Node gateway talk to the app via local IPC. +- A small surface for automation: Gateway + node commands, plus PeekabooBridge for UI automation. - Predictable permissions: always the same signed bundle ID, launched by launchd, so TCC grants stick. -- Limit who can connect: only signed clients from our team (with an explicit DEBUG-only escape hatch for development). ## How it works -### Control socket (current) -- `clawdis-mac` talks to the app via a local UNIX socket (`controlSocketPath`) for app-specific requests (notify, status, ensure-permissions, run, etc.). +### Gateway + node bridge (current) +- The app runs the Gateway (local mode) and connects to it as a node. +- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`). ### PeekabooBridge (UI automation) - UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol. @@ -24,29 +24,17 @@ Note: the current implementation primarily uses a local UNIX-domain control sock - See: `docs/mac/peekaboo.md` for the Clawdis plan and naming. ### Mach/XPC (future direction) -- The app registers a Mach service named `com.steipete.clawdis.xpc` via a user LaunchAgent at `~/Library/LaunchAgents/com.steipete.clawdis.plist`. -- The launch agent runs `dist/Clawdis.app/Contents/MacOS/Clawdis` with `RunAtLoad=true`, `KeepAlive=false`, and a `MachServices` entry for the XPC name. -- The app hosts the XPC listener (`NSXPCListener(machServiceName:)`) and exports `ClawdisXPCService`. -- The CLI (`clawdis-mac`) connects with `NSXPCConnection(machServiceName:)`; the Node gateway shells out to the CLI. -- Security: on incoming connections we read the audit token (or PID) and allow only: - - Code-signed clients with team ID `Y5PE65HELJ`. - - In `DEBUG` builds only, you can opt into allowing same-UID clients by setting `CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1`. +- Still optional for internal app services, but **not required** for automation now that node.invoke is the surface. ## Operational flows - Restart/rebuild: `SIGN_IDENTITY="Apple Development: Peter Steinberger (2ZAC4GM7GD)" scripts/restart-mac.sh` - Kills existing instances - Swift build + package - Writes/bootstraps/kickstarts the LaunchAgent -- CLI version: `clawdis-mac --version` (pulled from `package.json` during packaging) - Single instance: app exits early if another instance with the same bundle ID is running. -## Why launchd (not anonymous endpoints) -- A Mach service avoids brittle endpoint handoffs and lets the CLI/Node connect even if the app was started by launchd. -- RunAtLoad without KeepAlive means the app starts once; if it crashes it stays down (no unwanted respawn), but CLI calls will re-spawn via launchd. - ## Hardening notes - Prefer requiring a TeamID match for all privileged surfaces. - - Clawdis control socket: `CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development. - - PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development. +- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development. - All communication remains local-only; no network sockets are exposed. -- TCC prompts originate only from the GUI app bundle; run scripts/package-mac-app.sh so the signed bundle ID stays stable. +- TCC prompts originate only from the GUI app bundle; run `scripts/package-mac-app.sh` so the signed bundle ID stays stable. diff --git a/docs/nodes.md b/docs/nodes.md index d754e0d17..5f6f7c282 100644 --- a/docs/nodes.md +++ b/docs/nodes.md @@ -1,5 +1,5 @@ --- -summary: "Nodes: pairing, capabilities (canvas/camera), and the CLI helpers for screenshots + clips" +summary: "Nodes: pairing, capabilities, permissions, and CLI helpers for canvas/camera/screen/system" read_when: - Pairing iOS/Android nodes to a gateway - Using node canvas/camera for agent context @@ -8,7 +8,7 @@ read_when: # Nodes -A **node** is a companion device (iOS/Android today) that connects to the Gateway over the **Bridge** and exposes a small command surface (e.g. `canvas.*`, `camera.*`) via `node.invoke`. +A **node** is a companion device (iOS/Android today) that connects to the Gateway over the **Bridge** and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`. macOS can also run in **node mode**: the menubar app connects to the Gateway’s bridge and exposes its local canvas/camera commands as a node (so `clawdis nodes …` works against this Mac). @@ -90,6 +90,25 @@ Notes: - Screen recordings are clamped to `<= 60s`. - `--no-audio` disables microphone capture (supported on iOS/Android; macOS uses system capture audio). +## System commands (mac node) + +The macOS node exposes `system.run` and `system.notify`. + +Examples: + +```bash +clawdis nodes run --node -- echo "Hello from mac node" +clawdis nodes notify --node --title "Ping" --body "Gateway ready" +``` + +Notes: +- `system.run` returns stdout/stderr/exit code in the payload. +- `system.notify` respects notification permission state on the macOS app. + +## Permissions map + +Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted). + ## Mac node mode - The macOS menubar app connects to the Gateway bridge as a node (so `clawdis nodes …` works against this Mac). diff --git a/docs/refactor/canvas-a2ui.md b/docs/refactor/canvas-a2ui.md index 853935e8b..1311fd803 100644 --- a/docs/refactor/canvas-a2ui.md +++ b/docs/refactor/canvas-a2ui.md @@ -20,7 +20,7 @@ - Avoid double-sending actions when the bundled A2UI shell is present (let the shell forward clicks so it can resolve richer context). - Intercept `clawdis://…` navigations inside the Canvas WKWebView and route them through `DeepLinkHandler` (no NSWorkspace bounce). - `GatewayConnection` auto-starts the local gateway (and retries briefly) when a request fails in `.local` mode, so Canvas actions don’t silently fail if the gateway isn’t running yet. - - Fix a crash that made `clawdis-mac canvas present`/`eval` look “hung”: + - Fix a crash that made `clawdis canvas present`/`eval` look “hung”: - `VoicePushToTalkHotkey`’s NSEvent monitor could call `@MainActor` code off-main, triggering executor checks / EXC_BAD_ACCESS on macOS 26.2. - Now it hops back to the main actor before mutating state. - Preserve in-page state when closing Canvas (hide the window instead of closing the `WKWebView`). diff --git a/docs/refactor/cli-unification.md b/docs/refactor/cli-unification.md new file mode 100644 index 000000000..e39ba2a5e --- /dev/null +++ b/docs/refactor/cli-unification.md @@ -0,0 +1,64 @@ +--- +summary: "Refactor: unify on the clawdis CLI + gateway-first control; retire clawdis-mac" +read_when: + - Removing or replacing the macOS CLI helper + - Adding node capabilities or permissions metadata + - Updating macOS app packaging/install flows +--- + +# CLI unification (clawdis-only) + +Status: active refactor · Date: 2025-12-20 + +## Goals +- **Single CLI**: use `clawdis` for all automation (local + remote). Retire `clawdis-mac`. +- **Gateway-first**: all agent actions flow through the Gateway WebSocket + node.invoke. +- **Permission awareness**: nodes advertise permission state so the agent can decide what to run. +- **No duplicate paths**: remove macOS control socket + Swift CLI surface. + +## Non-goals +- Keep legacy `clawdis-mac` compatibility. +- Support agent control when no Gateway is running. + +## Key decisions +1) **No Gateway → no control** + - If the macOS app is running but the Gateway is not, remote commands (canvas/run/notify) are unavailable. + - This is acceptable to keep one network surface. + +2) **Remove ensure-permissions CLI** + - Permissions are **advertised by the node** (e.g., screen recording granted/denied). + - Commands will still fail with explicit errors when permissions are missing. + +3) **Mac app installs/symlinks `clawdis`** + - Bundle a standalone `clawdis` binary in the app (bun-compiled). + - Install/symlink that binary to `/usr/local/bin/clawdis` and `/opt/homebrew/bin/clawdis`. + - No `clawdis-mac` helper remains. + +4) **Canvas parity across node types** + - Use `node.invoke` commands consistently (`canvas.present|navigate|eval|snapshot|a2ui.*`). + - The TS CLI provides convenient wrappers so agents never have to craft raw `node.invoke` calls. + +## Command surface (new/normalized) +- `clawdis nodes invoke --command canvas.*` remains valid. +- New CLI wrappers for convenience: + - `clawdis canvas present|navigate|eval|snapshot|a2ui push|a2ui reset` +- New node commands (mac-only initially): + - `system.run` (shell execution) + - `system.notify` (local notifications) + +## Permission advertising +- Node hello/pairing includes a `permissions` map: + - Example keys: `screenRecording`, `accessibility`, `microphone`, `notifications`, `speechRecognition`. + - Values: boolean (`true` = granted, `false` = not granted). +- Gateway `node.list` / `node.describe` surfaces the map. + +## Gateway mode + config +- Gateways should only auto-start when explicitly configured for **local** mode. +- When config is missing or explicitly remote, `clawdis gateway` should refuse to auto-start unless forced. + +## Implementation checklist +- Add bun-compiled `clawdis` binary to macOS app bundle; update codesign + install flows. +- Remove `ClawdisCLI` target and control socket server. +- Add node command(s) for `system.run` and `system.notify` on macOS. +- Add permission map to node hello/pairing + gateway responses. +- Update TS CLI + docs to use `clawdis` only. diff --git a/scripts/codesign-mac-app.sh b/scripts/codesign-mac-app.sh index 29f2d24dc..98800055a 100755 --- a/scripts/codesign-mac-app.sh +++ b/scripts/codesign-mac-app.sh @@ -126,13 +126,10 @@ sign_plain_item() { codesign --force --options runtime --timestamp=none --sign "$IDENTITY" "$target" } -# Sign main binary and CLI helper if present +# Sign main binary if [ -f "$APP_BUNDLE/Contents/MacOS/Clawdis" ]; then echo "Signing main binary"; sign_item "$APP_BUNDLE/Contents/MacOS/Clawdis" "$APP_ENTITLEMENTS" fi -if [ -f "$APP_BUNDLE/Contents/MacOS/ClawdisCLI" ]; then - echo "Signing CLI helper"; sign_item "$APP_BUNDLE/Contents/MacOS/ClawdisCLI" "$ENT_TMP_BASE" -fi # Sign bundled gateway payload (native addons, libvips dylibs) if [ -d "$APP_BUNDLE/Contents/Resources/Relay" ]; then @@ -142,6 +139,9 @@ if [ -d "$APP_BUNDLE/Contents/Resources/Relay" ]; then if [ -f "$APP_BUNDLE/Contents/Resources/Relay/clawdis-gateway" ]; then echo "Signing embedded gateway"; sign_item "$APP_BUNDLE/Contents/Resources/Relay/clawdis-gateway" "$ENT_TMP_BUN" fi + if [ -f "$APP_BUNDLE/Contents/Resources/Relay/clawdis" ]; then + echo "Signing embedded CLI"; sign_item "$APP_BUNDLE/Contents/Resources/Relay/clawdis" "$ENT_TMP_BUN" + fi fi # Sign Sparkle deeply if present diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index c895b67a5..df3c2aec2 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -36,12 +36,10 @@ fi cd "$ROOT_DIR/apps/macos" echo "🔨 Building $PRODUCT ($BUILD_CONFIG)" -swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --product "${PRODUCT}CLI" --build-path "$BUILD_PATH" +swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH" BIN="$BUILD_PATH/$BUILD_CONFIG/$PRODUCT" -CLI_BIN="$BUILD_PATH/$BUILD_CONFIG/ClawdisCLI" echo "pkg: binary $BIN" >&2 -echo "pkg: cli $CLI_BIN" >&2 echo "🧹 Cleaning old app bundle" rm -rf "$APP_ROOT" mkdir -p "$APP_ROOT/Contents/MacOS" @@ -146,6 +144,18 @@ if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then --define "__CLAWDIS_VERSION__=\\\"$PKG_VERSION\\\"" chmod +x "$BUN_OUT" + echo "🧰 Building bundled CLI (bun --compile)" + CLI_OUT="$RELAY_DIR/clawdis" + bun build "$ROOT_DIR/dist/index.js" \ + --compile \ + --bytecode \ + --outfile "$CLI_OUT" \ + -e playwright-core \ + -e electron \ + -e "chromium-bidi*" \ + --define "__CLAWDIS_VERSION__=\\\"$PKG_VERSION\\\"" + chmod +x "$CLI_OUT" + echo "📄 Writing embedded runtime package.json (Pi compatibility)" cat > "$RELAY_DIR/package.json" </dev/null || true diff --git a/src/cli/canvas-cli.coverage.test.ts b/src/cli/canvas-cli.coverage.test.ts new file mode 100644 index 000000000..f2e1dbb48 --- /dev/null +++ b/src/cli/canvas-cli.coverage.test.ts @@ -0,0 +1,110 @@ +import { Command } from "commander"; +import { describe, expect, it, vi } from "vitest"; + +const callGateway = vi.fn( + async (opts: { method?: string; params?: { command?: string } }) => { + if (opts.method === "node.list") { + return { + nodes: [ + { + nodeId: "mac-1", + displayName: "Mac", + platform: "macos", + caps: ["canvas"], + connected: true, + }, + ], + }; + } + if (opts.method === "node.invoke") { + if (opts.params?.command === "canvas.eval") { + return { payload: { result: "ok" } }; + } + return { ok: true }; + } + return { ok: true }; + }, +); + +const randomIdempotencyKey = vi.fn(() => "rk_test"); + +const runtimeLogs: string[] = []; +const runtimeErrors: string[] = []; +const defaultRuntime = { + log: (msg: string) => runtimeLogs.push(msg), + error: (msg: string) => runtimeErrors.push(msg), + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, +}; + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGateway(opts as { method?: string }), + randomIdempotencyKey: () => randomIdempotencyKey(), +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); + +describe("canvas-cli coverage", () => { + it("invokes canvas.present with placement and target", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGateway.mockClear(); + randomIdempotencyKey.mockClear(); + + const { registerCanvasCli } = await import("./canvas-cli.js"); + const program = new Command(); + program.exitOverride(); + registerCanvasCli(program); + + await program.parseAsync( + [ + "canvas", + "present", + "--node", + "mac-1", + "--target", + "https://example.com", + "--x", + "10", + "--y", + "20", + "--width", + "800", + "--height", + "600", + ], + { from: "user" }, + ); + + const invoke = callGateway.mock.calls.find( + (call) => call[0]?.method === "node.invoke", + )?.[0]; + + expect(invoke).toBeTruthy(); + expect(invoke?.params?.command).toBe("canvas.present"); + expect(invoke?.params?.idempotencyKey).toBe("rk_test"); + expect(invoke?.params?.params).toEqual({ + url: "https://example.com", + placement: { x: 10, y: 20, width: 800, height: 600 }, + }); + }); + + it("prints canvas.eval result", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGateway.mockClear(); + + const { registerCanvasCli } = await import("./canvas-cli.js"); + const program = new Command(); + program.exitOverride(); + registerCanvasCli(program); + + await program.parseAsync(["canvas", "eval", "1+1"], { from: "user" }); + + expect(runtimeErrors).toHaveLength(0); + expect(runtimeLogs.join("\n")).toContain("ok"); + }); +}); diff --git a/src/cli/canvas-cli.ts b/src/cli/canvas-cli.ts index ea08ff9d0..f53c5b30c 100644 --- a/src/cli/canvas-cli.ts +++ b/src/cli/canvas-cli.ts @@ -1,3 +1,5 @@ +import fs from "node:fs/promises"; + import type { Command } from "commander"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { defaultRuntime } from "../runtime.js"; @@ -13,6 +15,13 @@ type CanvasOpts = { timeout?: string; json?: boolean; node?: string; + target?: string; + x?: string; + y?: string; + width?: string; + height?: string; + js?: string; + jsonl?: string; format?: string; maxWidth?: string; quality?: string; @@ -176,7 +185,21 @@ function normalizeFormat(format: string) { export function registerCanvasCli(program: Command) { const canvas = program .command("canvas") - .description("Render the canvas to a snapshot via nodes"); + .description("Control node canvases (present/navigate/eval/snapshot/a2ui)"); + + const invokeCanvas = async ( + opts: CanvasOpts, + command: string, + params?: Record, + ) => { + const nodeId = await resolveNodeId(opts, opts.node); + await callGatewayCli("node.invoke", opts, { + nodeId, + command, + params, + idempotencyKey: randomIdempotencyKey(), + }); + }; canvasCallOpts( canvas @@ -242,4 +265,161 @@ export function registerCanvasCli(program: Command) { } }), ); + + canvasCallOpts( + canvas + .command("present") + .description("Show the canvas (optionally with a target URL/path)") + .option("--node ", "Node id, name, or IP") + .option("--target ", "Target URL/path (optional)") + .option("--x ", "Placement x coordinate") + .option("--y ", "Placement y coordinate") + .option("--width ", "Placement width") + .option("--height ", "Placement height") + .action(async (opts: CanvasOpts) => { + try { + const placement = { + x: opts.x ? Number.parseFloat(opts.x) : undefined, + y: opts.y ? Number.parseFloat(opts.y) : undefined, + width: opts.width ? Number.parseFloat(opts.width) : undefined, + height: opts.height ? Number.parseFloat(opts.height) : undefined, + }; + const params: Record = {}; + if (opts.target) params.url = String(opts.target); + if ( + Number.isFinite(placement.x) || + Number.isFinite(placement.y) || + Number.isFinite(placement.width) || + Number.isFinite(placement.height) + ) { + params.placement = placement; + } + await invokeCanvas(opts, "canvas.present", params); + if (!opts.json) { + defaultRuntime.log("canvas present ok"); + } + } catch (err) { + defaultRuntime.error(`canvas present failed: ${String(err)}`); + defaultRuntime.exit(1); + } + }), + ); + + canvasCallOpts( + canvas + .command("hide") + .description("Hide the canvas") + .option("--node ", "Node id, name, or IP") + .action(async (opts: CanvasOpts) => { + try { + await invokeCanvas(opts, "canvas.hide", undefined); + if (!opts.json) { + defaultRuntime.log("canvas hide ok"); + } + } catch (err) { + defaultRuntime.error(`canvas hide failed: ${String(err)}`); + defaultRuntime.exit(1); + } + }), + ); + + canvasCallOpts( + canvas + .command("navigate") + .description("Navigate the canvas to a URL") + .argument("", "Target URL/path") + .option("--node ", "Node id, name, or IP") + .action(async (url: string, opts: CanvasOpts) => { + try { + await invokeCanvas(opts, "canvas.navigate", { url }); + if (!opts.json) { + defaultRuntime.log("canvas navigate ok"); + } + } catch (err) { + defaultRuntime.error(`canvas navigate failed: ${String(err)}`); + defaultRuntime.exit(1); + } + }), + ); + + canvasCallOpts( + canvas + .command("eval") + .description("Evaluate JavaScript in the canvas") + .argument("[js]", "JavaScript to evaluate") + .option("--js ", "JavaScript to evaluate") + .option("--node ", "Node id, name, or IP") + .action(async (jsArg: string | undefined, opts: CanvasOpts) => { + try { + const js = opts.js ?? jsArg; + if (!js) throw new Error("missing --js or "); + const nodeId = await resolveNodeId(opts, opts.node); + const raw = (await callGatewayCli("node.invoke", opts, { + nodeId, + command: "canvas.eval", + params: { javaScript: js }, + idempotencyKey: randomIdempotencyKey(), + })) as unknown; + if (opts.json) { + defaultRuntime.log(JSON.stringify(raw, null, 2)); + return; + } + const payload = + typeof raw === "object" && raw !== null + ? (raw as { payload?: { result?: string } }).payload + : undefined; + if (payload?.result) { + defaultRuntime.log(payload.result); + } else { + defaultRuntime.log("canvas eval ok"); + } + } catch (err) { + defaultRuntime.error(`canvas eval failed: ${String(err)}`); + defaultRuntime.exit(1); + } + }), + ); + + const a2ui = canvas + .command("a2ui") + .description("Render A2UI content on the canvas"); + + canvasCallOpts( + a2ui + .command("push") + .description("Push A2UI JSONL to the canvas") + .option("--jsonl ", "Path to JSONL payload") + .option("--node ", "Node id, name, or IP") + .action(async (opts: CanvasOpts) => { + try { + if (!opts.jsonl) throw new Error("missing --jsonl"); + const jsonl = await fs.readFile(String(opts.jsonl), "utf8"); + await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl }); + if (!opts.json) { + defaultRuntime.log("canvas a2ui push ok"); + } + } catch (err) { + defaultRuntime.error(`canvas a2ui push failed: ${String(err)}`); + defaultRuntime.exit(1); + } + }), + ); + + canvasCallOpts( + a2ui + .command("reset") + .description("Reset A2UI renderer state") + .option("--node ", "Node id, name, or IP") + .action(async (opts: CanvasOpts) => { + try { + await invokeCanvas(opts, "canvas.a2ui.reset", undefined); + if (!opts.json) { + defaultRuntime.log("canvas a2ui reset ok"); + } + } catch (err) { + defaultRuntime.error(`canvas a2ui reset failed: ${String(err)}`); + defaultRuntime.exit(1); + } + }), + ); } diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index 5b4a7cef4..e043234e8 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -152,9 +152,10 @@ describe("gateway-cli coverage", () => { programForceFail.exitOverride(); registerGatewayCli(programForceFail); await expect( - programForceFail.parseAsync(["gateway", "--port", "18789", "--force"], { - from: "user", - }), + programForceFail.parseAsync( + ["gateway", "--port", "18789", "--force", "--allow-unconfigured"], + { from: "user" }, + ), ).rejects.toThrow("__exit__:1"); // Start failure (generic) @@ -165,9 +166,10 @@ describe("gateway-cli coverage", () => { const beforeSigterm = new Set(process.listeners("SIGTERM")); const beforeSigint = new Set(process.listeners("SIGINT")); await expect( - programStartFail.parseAsync(["gateway", "--port", "18789"], { - from: "user", - }), + programStartFail.parseAsync( + ["gateway", "--port", "18789", "--allow-unconfigured"], + { from: "user" }, + ), ).rejects.toThrow("__exit__:1"); for (const listener of process.listeners("SIGTERM")) { if (!beforeSigterm.has(listener)) diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 944f72314..4d56a5e84 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -1,5 +1,7 @@ +import fs from "node:fs"; + import type { Command } from "commander"; -import { loadConfig } from "../config/config.js"; +import { CONFIG_PATH_CLAWDIS, loadConfig } from "../config/config.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { startGatewayServer } from "../gateway/server.js"; import { @@ -55,6 +57,11 @@ export function registerGatewayCli(program: Command) { "--token ", "Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)", ) + .option( + "--allow-unconfigured", + "Allow gateway start without gateway.mode=local in config", + false, + ) .option( "--force", "Kill any existing listener on the target port before starting", @@ -135,6 +142,21 @@ export function registerGatewayCli(program: Command) { process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token); } const cfg = loadConfig(); + const configExists = fs.existsSync(CONFIG_PATH_CLAWDIS); + const mode = cfg.gateway?.mode; + if (!opts.allowUnconfigured && mode !== "local") { + if (!configExists) { + defaultRuntime.error( + "Missing config. Run `clawdis setup` or set gateway.mode=local (or pass --allow-unconfigured).", + ); + } else { + defaultRuntime.error( + "Gateway start blocked: set gateway.mode=local (or pass --allow-unconfigured).", + ); + } + defaultRuntime.exit(1); + return; + } const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback"); const bind = bindRaw === "loopback" || diff --git a/src/cli/gateway.sigterm.test.ts b/src/cli/gateway.sigterm.test.ts index be244222d..dab9ec740 100644 --- a/src/cli/gateway.sigterm.test.ts +++ b/src/cli/gateway.sigterm.test.ts @@ -79,7 +79,15 @@ describe("gateway SIGTERM", () => { child = spawn( process.execPath, - ["--import", "tsx", "src/index.ts", "gateway", "--port", String(port)], + [ + "--import", + "tsx", + "src/index.ts", + "gateway", + "--port", + String(port), + "--allow-unconfigured", + ], { cwd: process.cwd(), env: { diff --git a/src/cli/nodes-cli.coverage.test.ts b/src/cli/nodes-cli.coverage.test.ts new file mode 100644 index 000000000..48e9a503d --- /dev/null +++ b/src/cli/nodes-cli.coverage.test.ts @@ -0,0 +1,161 @@ +import { Command } from "commander"; +import { describe, expect, it, vi } from "vitest"; + +const callGateway = vi.fn(async (opts: { method?: string }) => { + if (opts.method === "node.list") { + return { + nodes: [ + { + nodeId: "mac-1", + displayName: "Mac", + platform: "macos", + caps: ["canvas"], + connected: true, + permissions: { screenRecording: true }, + }, + ], + }; + } + if (opts.method === "node.invoke") { + return { + payload: { + stdout: "", + stderr: "", + exitCode: 0, + success: true, + timedOut: false, + }, + }; + } + return { ok: true }; +}); + +const randomIdempotencyKey = vi.fn(() => "rk_test"); + +const runtimeLogs: string[] = []; +const runtimeErrors: string[] = []; +const defaultRuntime = { + log: (msg: string) => runtimeLogs.push(msg), + error: (msg: string) => runtimeErrors.push(msg), + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, +}; + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGateway(opts as { method?: string }), + randomIdempotencyKey: () => randomIdempotencyKey(), +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); + +describe("nodes-cli coverage", () => { + it("lists nodes via node.list", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGateway.mockClear(); + + const { registerNodesCli } = await import("./nodes-cli.js"); + const program = new Command(); + program.exitOverride(); + registerNodesCli(program); + + await program.parseAsync(["nodes", "status"], { from: "user" }); + + expect(callGateway).toHaveBeenCalled(); + expect(callGateway.mock.calls[0]?.[0]?.method).toBe("node.list"); + expect(runtimeErrors).toHaveLength(0); + }); + + it("invokes system.run with parsed params", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGateway.mockClear(); + randomIdempotencyKey.mockClear(); + + const { registerNodesCli } = await import("./nodes-cli.js"); + const program = new Command(); + program.exitOverride(); + registerNodesCli(program); + + await program.parseAsync( + [ + "nodes", + "run", + "--node", + "mac-1", + "--cwd", + "/tmp", + "--env", + "FOO=bar", + "--command-timeout", + "1200", + "--needs-screen-recording", + "--invoke-timeout", + "5000", + "echo", + "hi", + ], + { from: "user" }, + ); + + const invoke = callGateway.mock.calls.find( + (call) => call[0]?.method === "node.invoke", + )?.[0]; + + expect(invoke).toBeTruthy(); + expect(invoke?.params?.idempotencyKey).toBe("rk_test"); + expect(invoke?.params?.command).toBe("system.run"); + expect(invoke?.params?.params).toEqual({ + command: ["echo", "hi"], + cwd: "/tmp", + env: { FOO: "bar" }, + timeoutMs: 1200, + needsScreenRecording: true, + }); + expect(invoke?.params?.timeoutMs).toBe(5000); + }); + + it("invokes system.notify with provided fields", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGateway.mockClear(); + + const { registerNodesCli } = await import("./nodes-cli.js"); + const program = new Command(); + program.exitOverride(); + registerNodesCli(program); + + await program.parseAsync( + [ + "nodes", + "notify", + "--node", + "mac-1", + "--title", + "Ping", + "--body", + "Gateway ready", + "--delivery", + "overlay", + ], + { from: "user" }, + ); + + const invoke = callGateway.mock.calls.find( + (call) => call[0]?.method === "node.invoke", + )?.[0]; + + expect(invoke).toBeTruthy(); + expect(invoke?.params?.command).toBe("system.notify"); + expect(invoke?.params?.params).toEqual({ + title: "Ping", + body: "Gateway ready", + sound: undefined, + priority: undefined, + delivery: "overlay", + }); + }); +}); diff --git a/src/cli/nodes-cli.ts b/src/cli/nodes-cli.ts index e1cfc8e75..1dc0bd36e 100644 --- a/src/cli/nodes-cli.ts +++ b/src/cli/nodes-cli.ts @@ -29,6 +29,15 @@ type NodesRpcOpts = { params?: string; invokeTimeout?: string; idempotencyKey?: string; + cwd?: string; + env?: string[]; + commandTimeout?: string; + needsScreenRecording?: boolean; + title?: string; + body?: string; + sound?: string; + priority?: string; + delivery?: string; facing?: string; format?: string; maxWidth?: string; @@ -49,6 +58,7 @@ type NodeListNode = { modelIdentifier?: string; caps?: string[]; commands?: string[]; + permissions?: Record; paired?: boolean; connected?: boolean; }; @@ -71,6 +81,7 @@ type PairedNode = { platform?: string; version?: string; remoteIp?: string; + permissions?: Record; createdAtMs?: number; approvedAtMs?: number; }; @@ -137,6 +148,19 @@ function parseNodeList(value: unknown): NodeListNode[] { return Array.isArray(obj.nodes) ? (obj.nodes as NodeListNode[]) : []; } +function formatPermissions(raw: unknown) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const entries = Object.entries(raw as Record) + .map(([key, value]) => [String(key).trim(), value === true] as const) + .filter(([key]) => key.length > 0) + .sort((a, b) => a[0].localeCompare(b[0])); + if (entries.length === 0) return null; + const parts = entries.map( + ([key, granted]) => `${key}=${granted ? "yes" : "no"}`, + ); + return `[${parts.join(", ")}]`; +} + function normalizeNodeKey(value: string) { return value .toLowerCase() @@ -145,6 +169,20 @@ function normalizeNodeKey(value: string) { .replace(/-+$/, ""); } +function parseEnvPairs(pairs: string[] | undefined) { + if (!Array.isArray(pairs) || pairs.length === 0) return undefined; + const env: Record = {}; + for (const pair of pairs) { + const idx = pair.indexOf("="); + if (idx <= 0) continue; + const key = pair.slice(0, idx).trim(); + const value = pair.slice(idx + 1); + if (!key) continue; + env[key] = value; + } + return Object.keys(env).length > 0 ? env : undefined; +} + async function resolveNodeId(opts: NodesRpcOpts, query: string) { const q = String(query ?? "").trim(); if (!q) throw new Error("node required"); @@ -223,6 +261,8 @@ export function registerNodesCli(program: Command) { const ip = n.remoteIp ? ` · ${n.remoteIp}` : ""; const device = n.deviceFamily ? ` · device: ${n.deviceFamily}` : ""; const hw = n.modelIdentifier ? ` · hw: ${n.modelIdentifier}` : ""; + const perms = formatPermissions(n.permissions); + const permsText = perms ? ` · perms: ${perms}` : ""; const caps = Array.isArray(n.caps) && n.caps.length > 0 ? `[${n.caps.map(String).filter(Boolean).sort().join(",")}]` @@ -231,7 +271,7 @@ export function registerNodesCli(program: Command) { : "?"; const pairing = n.paired ? "paired" : "unpaired"; defaultRuntime.log( - `- ${name} · ${n.nodeId}${ip}${device}${hw} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`, + `- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`, ); } } catch (err) { @@ -270,6 +310,7 @@ export function registerNodesCli(program: Command) { const commands = Array.isArray(obj.commands) ? obj.commands.map(String).filter(Boolean).sort() : []; + const perms = formatPermissions(obj.permissions); const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null; const model = @@ -282,6 +323,7 @@ export function registerNodesCli(program: Command) { if (ip) parts.push(ip); if (family) parts.push(`device: ${family}`); if (model) parts.push(`hw: ${model}`); + if (perms) parts.push(`perms: ${perms}`); parts.push(connected ? "connected" : "disconnected"); parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`); defaultRuntime.log(parts.join(" · ")); @@ -474,6 +516,173 @@ export function registerNodesCli(program: Command) { { timeoutMs: 30_000 }, ); + nodesCallOpts( + nodes + .command("run") + .description("Run a shell command on a node (mac only)") + .requiredOption("--node ", "Node id, name, or IP") + .option("--cwd ", "Working directory") + .option( + "--env ", + "Environment override (repeatable)", + (value: string, prev: string[] = []) => [...prev, value], + ) + .option("--command-timeout ", "Command timeout (ms)") + .option("--needs-screen-recording", "Require screen recording permission") + .option( + "--invoke-timeout ", + "Node invoke timeout in ms (default 30000)", + "30000", + ) + .argument("", "Command and args") + .action(async (command: string[], opts: NodesRpcOpts) => { + try { + const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); + if (!Array.isArray(command) || command.length === 0) { + throw new Error("command required"); + } + const env = parseEnvPairs(opts.env); + const timeoutMs = opts.commandTimeout + ? Number.parseInt(String(opts.commandTimeout), 10) + : undefined; + const invokeTimeout = opts.invokeTimeout + ? Number.parseInt(String(opts.invokeTimeout), 10) + : undefined; + + const invokeParams: Record = { + nodeId, + command: "system.run", + params: { + command, + cwd: opts.cwd, + env, + timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined, + needsScreenRecording: opts.needsScreenRecording === true, + }, + idempotencyKey: String( + opts.idempotencyKey ?? randomIdempotencyKey(), + ), + }; + if ( + typeof invokeTimeout === "number" && + Number.isFinite(invokeTimeout) + ) { + invokeParams.timeoutMs = invokeTimeout; + } + + const result = (await callGatewayCli( + "node.invoke", + opts, + invokeParams, + )) as unknown; + + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + + const payload = + typeof result === "object" && result !== null + ? (result as { payload?: Record }).payload + : undefined; + + const stdout = + typeof payload?.stdout === "string" ? payload.stdout : ""; + const stderr = + typeof payload?.stderr === "string" ? payload.stderr : ""; + const exitCode = + typeof payload?.exitCode === "number" ? payload.exitCode : null; + const timedOut = payload?.timedOut === true; + const success = payload?.success === true; + + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + if (timedOut) { + defaultRuntime.error("run timed out"); + defaultRuntime.exit(1); + return; + } + if (exitCode !== null && exitCode !== 0 && !success) { + defaultRuntime.error(`run exit ${exitCode}`); + defaultRuntime.exit(1); + return; + } + } catch (err) { + defaultRuntime.error(`nodes run failed: ${String(err)}`); + defaultRuntime.exit(1); + } + }), + { timeoutMs: 35_000 }, + ); + + nodesCallOpts( + nodes + .command("notify") + .description("Send a local notification on a node (mac only)") + .requiredOption("--node ", "Node id, name, or IP") + .option("--title ", "Notification title") + .option("--body ", "Notification body") + .option("--sound ", "Notification sound") + .option( + "--priority ", + "Notification priority", + ) + .option("--delivery ", "Delivery mode", "system") + .option( + "--invoke-timeout ", + "Node invoke timeout in ms (default 15000)", + "15000", + ) + .action(async (opts: NodesRpcOpts) => { + try { + const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); + const title = String(opts.title ?? "").trim(); + const body = String(opts.body ?? "").trim(); + if (!title && !body) { + throw new Error("missing --title or --body"); + } + const invokeTimeout = opts.invokeTimeout + ? Number.parseInt(String(opts.invokeTimeout), 10) + : undefined; + const invokeParams: Record = { + nodeId, + command: "system.notify", + params: { + title, + body, + sound: opts.sound, + priority: opts.priority, + delivery: opts.delivery, + }, + idempotencyKey: String( + opts.idempotencyKey ?? randomIdempotencyKey(), + ), + }; + if ( + typeof invokeTimeout === "number" && + Number.isFinite(invokeTimeout) + ) { + invokeParams.timeoutMs = invokeTimeout; + } + + const result = await callGatewayCli( + "node.invoke", + opts, + invokeParams, + ); + + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + defaultRuntime.log("notify ok"); + } catch (err) { + defaultRuntime.error(`nodes notify failed: ${String(err)}`); + defaultRuntime.exit(1); + } + }), + ); + const parseFacing = (value: string): CameraFacing => { const v = String(value ?? "") .trim() diff --git a/src/config/config.ts b/src/config/config.ts index 4047d3742..275fb373a 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -107,6 +107,11 @@ export type GatewayControlUiConfig = { }; export type GatewayConfig = { + /** + * Explicit gateway mode. When set to "remote", local gateway start is disabled. + * When set to "local", the CLI may start the gateway locally. + */ + mode?: "local" | "remote"; /** * Bind address policy for the Gateway WebSocket + Control UI HTTP server. * Default: loopback (127.0.0.1). @@ -328,6 +333,7 @@ const ClawdisSchema = z.object({ .optional(), gateway: z .object({ + mode: z.union([z.literal("local"), z.literal("remote")]).optional(), bind: z .union([ z.literal("auto"), diff --git a/src/gateway/server.ts b/src/gateway/server.ts index ebd870db8..9ef353ecb 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -3516,6 +3516,7 @@ export async function startGatewayServer( remoteIp: live?.remoteIp ?? paired?.remoteIp, caps, commands, + permissions: live?.permissions ?? paired?.permissions, paired: Boolean(paired), connected: Boolean(live), }; @@ -3609,6 +3610,7 @@ export async function startGatewayServer( remoteIp: live?.remoteIp ?? paired?.remoteIp, caps, commands, + permissions: live?.permissions ?? paired?.permissions, paired: Boolean(paired), connected: Boolean(live), }, diff --git a/src/infra/bridge/server.test.ts b/src/infra/bridge/server.test.ts index 8c560db62..e9ad67996 100644 --- a/src/infra/bridge/server.test.ts +++ b/src/infra/bridge/server.test.ts @@ -228,6 +228,7 @@ describe("node bridge server", () => { deviceFamily?: string; modelIdentifier?: string; remoteIp?: string; + permissions?: Record; } | null = null; let disconnected: { @@ -238,6 +239,7 @@ describe("node bridge server", () => { deviceFamily?: string; modelIdentifier?: string; remoteIp?: string; + permissions?: Record; } | null = null; let resolveDisconnected: (() => void) | null = null; @@ -268,6 +270,7 @@ describe("node bridge server", () => { version: "1.0", deviceFamily: "iPad", modelIdentifier: "iPad16,6", + permissions: { screenRecording: true, notifications: false }, }); // Approve the pending request from the gateway side. @@ -304,6 +307,7 @@ describe("node bridge server", () => { version: "2.0", deviceFamily: "iPad", modelIdentifier: "iPad99,1", + permissions: { screenRecording: false }, }); const line3 = JSON.parse(await readLine2()) as { type: string }; expect(line3.type).toBe("hello-ok"); @@ -320,6 +324,10 @@ describe("node bridge server", () => { expect(lastAuthed?.version).toBe("1.0"); expect(lastAuthed?.deviceFamily).toBe("iPad"); expect(lastAuthed?.modelIdentifier).toBe("iPad16,6"); + expect(lastAuthed?.permissions).toEqual({ + screenRecording: false, + notifications: false, + }); expect(lastAuthed?.remoteIp?.includes("127.0.0.1")).toBe(true); socket2.destroy(); @@ -432,6 +440,7 @@ describe("node bridge server", () => { modelIdentifier: "iPad14,5", caps: ["canvas", "camera"], commands: ["canvas.eval", "canvas.snapshot", "camera.snap"], + permissions: { accessibility: true }, }); // Approve the pending request from the gateway side. @@ -464,6 +473,7 @@ describe("node bridge server", () => { "canvas.snapshot", "camera.snap", ]); + expect(node?.permissions).toEqual({ accessibility: true }); const after = await listNodePairing(baseDir); const paired = after.paired.find((p) => p.nodeId === "n-caps"); @@ -473,6 +483,7 @@ describe("node bridge server", () => { "canvas.snapshot", "camera.snap", ]); + expect(paired?.permissions).toEqual({ accessibility: true }); socket.destroy(); await server.close(); diff --git a/src/infra/bridge/server.ts b/src/infra/bridge/server.ts index 21d728096..c425470d5 100644 --- a/src/infra/bridge/server.ts +++ b/src/infra/bridge/server.ts @@ -22,6 +22,7 @@ type BridgeHelloFrame = { modelIdentifier?: string; caps?: string[]; commands?: string[]; + permissions?: Record; }; type BridgePairRequestFrame = { @@ -34,6 +35,7 @@ type BridgePairRequestFrame = { modelIdentifier?: string; caps?: string[]; commands?: string[]; + permissions?: Record; remoteAddress?: string; silent?: boolean; }; @@ -123,6 +125,7 @@ export type NodeBridgeClientInfo = { remoteIp?: string; caps?: string[]; commands?: string[]; + permissions?: Record; }; export type NodeBridgeServerOpts = { @@ -288,6 +291,18 @@ export async function startNodeBridgeServer( return undefined; }; + const normalizePermissions = ( + raw: unknown, + ): Record | undefined => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + return undefined; + const entries = Object.entries(raw as Record) + .map(([key, value]) => [String(key).trim(), value === true] as const) + .filter(([key]) => key.length > 0); + if (entries.length === 0) return undefined; + return Object.fromEntries(entries); + }; + const caps = (Array.isArray(hello.caps) ? hello.caps.map((c) => String(c)).filter(Boolean) @@ -299,6 +314,10 @@ export async function startNodeBridgeServer( Array.isArray(hello.commands) && hello.commands.length > 0 ? hello.commands.map((c) => String(c)).filter(Boolean) : verified.node.commands; + const helloPermissions = normalizePermissions(hello.permissions); + const permissions = helloPermissions + ? { ...(verified.node.permissions ?? {}), ...helloPermissions } + : verified.node.permissions; isAuthenticated = true; const existing = connections.get(nodeId); @@ -318,6 +337,7 @@ export async function startNodeBridgeServer( modelIdentifier: verified.node.modelIdentifier ?? hello.modelIdentifier, caps, commands, + permissions, remoteIp: remoteAddress, }; await updatePairedNodeMetadata( @@ -331,6 +351,7 @@ export async function startNodeBridgeServer( remoteIp: nodeInfo.remoteIp, caps: nodeInfo.caps, commands: nodeInfo.commands, + permissions: nodeInfo.permissions, }, opts.pairingBaseDir, ); @@ -396,6 +417,10 @@ export async function startNodeBridgeServer( commands: Array.isArray(req.commands) ? req.commands.map((c) => String(c)).filter(Boolean) : undefined, + permissions: + req.permissions && typeof req.permissions === "object" + ? (req.permissions as Record) + : undefined, remoteIp: remoteAddress, silent: req.silent === true ? true : undefined, }, @@ -433,6 +458,10 @@ export async function startNodeBridgeServer( commands: Array.isArray(req.commands) ? req.commands.map((c) => String(c)).filter(Boolean) : undefined, + permissions: + req.permissions && typeof req.permissions === "object" + ? (req.permissions as Record) + : undefined, remoteIp: remoteAddress, }; connections.set(nodeId, { socket, nodeInfo, invokeWaiters }); diff --git a/src/infra/clawdis-mac.test.ts b/src/infra/clawdis-mac.test.ts deleted file mode 100644 index 7ad852022..000000000 --- a/src/infra/clawdis-mac.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import fsp from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { describe, expect, it, vi } from "vitest"; - -import type { RuntimeEnv } from "../runtime.js"; - -const runExecCalls = vi.hoisted( - () => [] as Array<{ cmd: string; args: string[] }>, -); -const runCommandCalls = vi.hoisted( - () => [] as Array<{ argv: string[]; timeoutMs: number }>, -); - -let runExecThrows = false; - -vi.mock("../process/exec.js", () => ({ - runExec: vi.fn(async (cmd: string, args: string[]) => { - runExecCalls.push({ cmd, args }); - if (runExecThrows) throw new Error("which failed"); - return { stdout: "/usr/local/bin/clawdis-mac\n", stderr: "" }; - }), - runCommandWithTimeout: vi.fn(async (argv: string[], timeoutMs: number) => { - runCommandCalls.push({ argv, timeoutMs }); - return { stdout: "ok", stderr: "", code: 0 }; - }), -})); - -import { resolveClawdisMacBinary, runClawdisMac } from "./clawdis-mac.js"; - -describe("clawdis-mac binary resolver", () => { - it("uses env override on macOS and errors elsewhere", async () => { - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: (code: number) => { - throw new Error(`exit ${code}`); - }, - }; - - if (process.platform === "darwin") { - vi.stubEnv("CLAWDIS_MAC_BIN", "/opt/bin/clawdis-mac"); - await expect(resolveClawdisMacBinary(runtime)).resolves.toBe( - "/opt/bin/clawdis-mac", - ); - return; - } - - await expect(resolveClawdisMacBinary(runtime)).rejects.toThrow(/exit 1/); - }); - - it("runs the helper with --json when requested", async () => { - if (process.platform !== "darwin") return; - vi.stubEnv("CLAWDIS_MAC_BIN", "/opt/bin/clawdis-mac"); - - const res = await runClawdisMac(["browser", "status"], { - json: true, - timeoutMs: 1234, - }); - - expect(res).toMatchObject({ stdout: "ok", code: 0 }); - expect(runCommandCalls.length).toBeGreaterThan(0); - expect(runCommandCalls.at(-1)?.argv).toEqual([ - "/opt/bin/clawdis-mac", - "--json", - "browser", - "status", - ]); - expect(runCommandCalls.at(-1)?.timeoutMs).toBe(1234); - }); - - it("falls back to `which clawdis-mac` when no override is set", async () => { - if (process.platform !== "darwin") return; - vi.stubEnv("CLAWDIS_MAC_BIN", ""); - - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: (code: number) => { - throw new Error(`exit ${code}`); - }, - }; - - const resolved = await resolveClawdisMacBinary(runtime); - expect(resolved).toBe("/usr/local/bin/clawdis-mac"); - expect(runExecCalls.some((c) => c.cmd === "which")).toBe(true); - }); - - it("falls back to ./bin/clawdis-mac when which fails", async () => { - if (process.platform !== "darwin") return; - - const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdis-mac-test-")); - const oldCwd = process.cwd(); - try { - const binDir = path.join(tmp, "bin"); - await fsp.mkdir(binDir, { recursive: true }); - const exePath = path.join(binDir, "clawdis-mac"); - await fsp.writeFile(exePath, "#!/bin/sh\necho ok\n", "utf-8"); - await fsp.chmod(exePath, 0o755); - - process.chdir(tmp); - vi.stubEnv("CLAWDIS_MAC_BIN", ""); - runExecThrows = true; - - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: (code: number) => { - throw new Error(`exit ${code}`); - }, - }; - - const resolved = await resolveClawdisMacBinary(runtime); - const expectedReal = await fsp.realpath(exePath); - const resolvedReal = await fsp.realpath(resolved); - expect(resolvedReal).toBe(expectedReal); - } finally { - runExecThrows = false; - process.chdir(oldCwd); - await fsp.rm(tmp, { recursive: true, force: true }); - } - }); -}); diff --git a/src/infra/clawdis-mac.ts b/src/infra/clawdis-mac.ts deleted file mode 100644 index 4417efc8f..000000000 --- a/src/infra/clawdis-mac.ts +++ /dev/null @@ -1,65 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -import { runCommandWithTimeout, runExec } from "../process/exec.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; - -export type ClawdisMacExecResult = { - stdout: string; - stderr: string; - code: number | null; -}; - -function isFileExecutable(p: string): boolean { - try { - const stat = fs.statSync(p); - if (!stat.isFile()) return false; - fs.accessSync(p, fs.constants.X_OK); - return true; - } catch { - return false; - } -} - -export async function resolveClawdisMacBinary( - runtime: RuntimeEnv = defaultRuntime, -): Promise { - if (process.platform !== "darwin") { - runtime.error("clawdis-mac is only available on macOS."); - runtime.exit(1); - } - - const override = process.env.CLAWDIS_MAC_BIN?.trim(); - if (override) return override; - - try { - const { stdout } = await runExec("which", ["clawdis-mac"], 2000); - const resolved = stdout.trim(); - if (resolved) return resolved; - } catch { - // fall through - } - - const local = path.resolve(process.cwd(), "bin", "clawdis-mac"); - if (isFileExecutable(local)) return local; - - runtime.error( - "Missing required binary: clawdis-mac. Install the Clawdis mac app/CLI helper (or set CLAWDIS_MAC_BIN).", - ); - runtime.exit(1); -} - -export async function runClawdisMac( - args: string[], - opts?: { json?: boolean; timeoutMs?: number; runtime?: RuntimeEnv }, -): Promise { - const runtime = opts?.runtime ?? defaultRuntime; - const cmd = await resolveClawdisMacBinary(runtime); - - const argv: string[] = [cmd]; - if (opts?.json) argv.push("--json"); - argv.push(...args); - - const res = await runCommandWithTimeout(argv, opts?.timeoutMs ?? 30_000); - return { stdout: res.stdout, stderr: res.stderr, code: res.code }; -} diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index 81d2887b4..62325f7dd 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -13,6 +13,7 @@ export type NodePairingPendingRequest = { modelIdentifier?: string; caps?: string[]; commands?: string[]; + permissions?: Record; remoteIp?: string; silent?: boolean; isRepair?: boolean; @@ -29,6 +30,7 @@ export type NodePairingPairedNode = { modelIdentifier?: string; caps?: string[]; commands?: string[]; + permissions?: Record; remoteIp?: string; createdAtMs: number; approvedAtMs: number; @@ -185,6 +187,7 @@ export async function requestNodePairing( modelIdentifier: req.modelIdentifier, caps: req.caps, commands: req.commands, + permissions: req.permissions, remoteIp: req.remoteIp, silent: req.silent, isRepair, @@ -217,6 +220,7 @@ export async function approveNodePairing( modelIdentifier: pending.modelIdentifier, caps: pending.caps, commands: pending.commands, + permissions: pending.permissions, remoteIp: pending.remoteIp, createdAtMs: existing?.createdAtMs ?? now, approvedAtMs: now, @@ -281,6 +285,7 @@ export async function updatePairedNodeMetadata( remoteIp: patch.remoteIp ?? existing.remoteIp, caps: patch.caps ?? existing.caps, commands: patch.commands ?? existing.commands, + permissions: patch.permissions ?? existing.permissions, }; state.pairedByNodeId[normalized] = next; diff --git a/src/infra/system-presence.test.ts b/src/infra/system-presence.test.ts index 606f325c8..828322105 100644 --- a/src/infra/system-presence.test.ts +++ b/src/infra/system-presence.test.ts @@ -12,7 +12,7 @@ describe("system-presence", () => { const instanceIdLower = instanceIdUpper.toLowerCase(); upsertPresence(instanceIdUpper, { - host: "clawdis-mac", + host: "clawdis", mode: "app", instanceId: instanceIdUpper, reason: "connect",