refactor(cli): unify on clawdis CLI + node permissions

This commit is contained in:
Peter Steinberger
2025-12-20 02:08:04 +00:00
parent 479720c169
commit 849446ae17
49 changed files with 1205 additions and 2735 deletions

View File

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

View File

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

View File

@@ -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<Void, Never>?
private let socketPath: String
private let maxRequestBytes: Int
private let allowedTeamIDs: Set<String>
private let requestTimeoutSec: TimeInterval
init(
socketPath: String = controlSocketPath,
maxRequestBytes: Int = 512 * 1024,
allowedTeamIDs: Set<String> = ["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<sockaddr>(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<String>,
maxRequestBytes: Int,
requestTimeoutSec: TimeInterval) async
{
while !Task.isCancelled {
var addr = sockaddr()
var len = socklen_t(MemoryLayout<sockaddr>.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<String>,
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<String>) -> Bool {
var pid: pid_t = 0
var pidSize = socklen_t(MemoryLayout<pid_t>.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<String>) -> 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

View File

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

View File

@@ -29,7 +29,7 @@ enum InstanceIdentity {
{
return name
}
return "clawdis-mac"
return "clawdis"
}()
static let modelIdentifier: String? = {

View File

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

View File

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

View File

@@ -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<T: Decodable>(_ 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: [

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,173 +0,0 @@
import ClawdisIPC
import Foundation
import Testing
@testable import Clawdis
@Suite(.serialized)
struct ControlRequestHandlerTests {
private static func withDefaultOverride<T>(
_ 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")
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:<jid>`; 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.

View File

@@ -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:<path>
clawdis-mac camera snap --max-width 1280
clawdis-mac camera clip --duration 10s # prints MEDIA:<path>
clawdis-mac camera clip --duration-ms 3000 # prints MEDIA:<path> (legacy flag)
clawdis-mac camera clip --no-audio
clawdis nodes camera snap --node <id> # prints MEDIA:<path>
clawdis nodes camera snap --node <id> --max-width 1280
clawdis nodes camera clip --node <id> --duration 10s # prints MEDIA:<path>
clawdis nodes camera clip --node <id> --duration-ms 3000 # prints MEDIA:<path> (legacy flag)
clawdis nodes camera clip --node <id> --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:<path>
clawdis nodes screen record --node <id> --duration 10s --fps 15 # prints MEDIA:<path>
```
Notes:

View File

@@ -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 VibeTunnels onboarding (permissions + CLI install).
- Runs (or connects to) the **Gateway** and exposes itself as a **node** so agents can reach macOSonly 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 appinternal 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` (buncompiled Gateway)
- `clawdis` (buncompiled 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.
- Agentfacing 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 macspecific actions, the CLI uses `node.invoke`:
- `clawdis canvas present|navigate|eval|snapshot|a2ui push|a2ui reset`
- `clawdis nodes run --node <id> -- <command...>`
- `clawdis nodes notify --node <id> --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 Trimmys “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 <id> --command <name> [--params-json <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 nodeside permission prompts, or always require explicit app UI action?

View File

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

View File

@@ -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://<lanHost>:<canvasPort>/` 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

View File

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

View File

@@ -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 <key>] [--target <...>] [--x/--y/--width/--height]`
- `clawdis canvas present [--node <id>] [--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 <key>]`
- `clawdis-mac canvas eval --js <code> [--session <key>]`
- `clawdis-mac canvas snapshot [--out <path>] [--session <key>]`
- `clawdis canvas hide [--node <id>]`
- `clawdis canvas eval --js <code> [--node <id>]`
- `clawdis canvas snapshot [--node <id>]`
### 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 <path> [--session <key>]`
- `clawdis-mac canvas a2ui reset [--session <key>]`
- `clawdis canvas a2ui push --jsonl <path> [--node <id>]`
- `clawdis canvas a2ui reset [--node <id>]`
`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 <id>
```
Notes:

View File

@@ -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 apps “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 apps 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 targets 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.

View File

@@ -67,7 +67,7 @@ What Clawdis should *not* embed:
- **XPC**: dont 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 isnt running.

View File

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

View File

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

View File

@@ -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 Gateways 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 <idOrNameOrIp> -- echo "Hello from mac node"
clawdis nodes notify --node <idOrNameOrIp> --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).

View File

@@ -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 dont silently fail if the gateway isnt 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`).

View File

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

View File

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

View File

@@ -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" <<JSON
{
@@ -173,12 +183,6 @@ else
echo "🧰 Skipping gateway payload packaging (SKIP_GATEWAY_PACKAGE=1)"
fi
if [ -f "$CLI_BIN" ]; then
echo "🔧 Copying CLI helper"
cp "$CLI_BIN" "$APP_ROOT/Contents/MacOS/ClawdisCLI"
chmod +x "$APP_ROOT/Contents/MacOS/ClawdisCLI"
fi
echo "⏹ Stopping any running Clawdis"
killall -q Clawdis 2>/dev/null || true

View File

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

View File

@@ -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<string, unknown>,
) => {
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 <idOrNameOrIp>", "Node id, name, or IP")
.option("--target <urlOrPath>", "Target URL/path (optional)")
.option("--x <px>", "Placement x coordinate")
.option("--y <px>", "Placement y coordinate")
.option("--width <px>", "Placement width")
.option("--height <px>", "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<string, unknown> = {};
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 <idOrNameOrIp>", "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("<url>", "Target URL/path")
.option("--node <idOrNameOrIp>", "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 <code>", "JavaScript to evaluate")
.option("--node <idOrNameOrIp>", "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 <js>");
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>", "Path to JSONL payload")
.option("--node <idOrNameOrIp>", "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 <idOrNameOrIp>", "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);
}
}),
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, boolean>;
paired?: boolean;
connected?: boolean;
};
@@ -71,6 +81,7 @@ type PairedNode = {
platform?: string;
version?: string;
remoteIp?: string;
permissions?: Record<string, boolean>;
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<string, unknown>)
.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<string, string> = {};
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 <idOrNameOrIp>", "Node id, name, or IP")
.option("--cwd <path>", "Working directory")
.option(
"--env <key=val>",
"Environment override (repeatable)",
(value: string, prev: string[] = []) => [...prev, value],
)
.option("--command-timeout <ms>", "Command timeout (ms)")
.option("--needs-screen-recording", "Require screen recording permission")
.option(
"--invoke-timeout <ms>",
"Node invoke timeout in ms (default 30000)",
"30000",
)
.argument("<command...>", "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<string, unknown> = {
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<string, unknown> }).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 <idOrNameOrIp>", "Node id, name, or IP")
.option("--title <text>", "Notification title")
.option("--body <text>", "Notification body")
.option("--sound <name>", "Notification sound")
.option(
"--priority <passive|active|timeSensitive>",
"Notification priority",
)
.option("--delivery <system|overlay|auto>", "Delivery mode", "system")
.option(
"--invoke-timeout <ms>",
"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<string, unknown> = {
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()

View File

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

View File

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

View File

@@ -228,6 +228,7 @@ describe("node bridge server", () => {
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
permissions?: Record<string, boolean>;
} | null = null;
let disconnected: {
@@ -238,6 +239,7 @@ describe("node bridge server", () => {
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
permissions?: Record<string, boolean>;
} | 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();

View File

@@ -22,6 +22,7 @@ type BridgeHelloFrame = {
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
};
type BridgePairRequestFrame = {
@@ -34,6 +35,7 @@ type BridgePairRequestFrame = {
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
remoteAddress?: string;
silent?: boolean;
};
@@ -123,6 +125,7 @@ export type NodeBridgeClientInfo = {
remoteIp?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
};
export type NodeBridgeServerOpts = {
@@ -288,6 +291,18 @@ export async function startNodeBridgeServer(
return undefined;
};
const normalizePermissions = (
raw: unknown,
): Record<string, boolean> | undefined => {
if (!raw || typeof raw !== "object" || Array.isArray(raw))
return undefined;
const entries = Object.entries(raw as Record<string, unknown>)
.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<string, boolean>)
: 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<string, boolean>)
: undefined,
remoteIp: remoteAddress,
};
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });

View File

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

View File

@@ -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<string> {
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<ClawdisMacExecResult> {
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 };
}

View File

@@ -13,6 +13,7 @@ export type NodePairingPendingRequest = {
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
remoteIp?: string;
silent?: boolean;
isRepair?: boolean;
@@ -29,6 +30,7 @@ export type NodePairingPairedNode = {
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
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;

View File

@@ -12,7 +12,7 @@ describe("system-presence", () => {
const instanceIdLower = instanceIdUpper.toLowerCase();
upsertPresence(instanceIdUpper, {
host: "clawdis-mac",
host: "clawdis",
mode: "app",
instanceId: instanceIdUpper,
reason: "connect",