refactor(cli): unify on clawdis CLI + node permissions
This commit is contained in:
@@ -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"),
|
||||
]),
|
||||
])
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -29,7 +29,7 @@ enum InstanceIdentity {
|
||||
{
|
||||
return name
|
||||
}
|
||||
return "clawdis-mac"
|
||||
return "clawdis"
|
||||
}()
|
||||
|
||||
static let modelIdentifier: String? = {
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,95 +1,57 @@
|
||||
---
|
||||
summary: "Spec for the Clawdis macOS companion menu bar app and local broker (control socket + PeekabooBridge)"
|
||||
summary: "Spec for the Clawdis macOS companion menu bar app (gateway + node broker)"
|
||||
read_when:
|
||||
- Implementing macOS app features
|
||||
- Touching broker/CLI bridging
|
||||
- Changing gateway lifecycle or node bridging on macOS
|
||||
---
|
||||
# Clawdis macOS Companion (menu bar + local broker)
|
||||
# Clawdis macOS Companion (menu bar + gateway broker)
|
||||
|
||||
Author: steipete · Status: draft spec · Date: 2025-12-05
|
||||
Author: steipete · Status: draft spec · Date: 2025-12-20
|
||||
|
||||
## Purpose
|
||||
- Single macOS menu-bar app named **Clawdis** that:
|
||||
- Shows native notifications for Clawdis/clawdis events.
|
||||
- Owns TCC prompts (Notifications, Accessibility, Screen Recording, Automation/AppleScript, Microphone, Speech Recognition).
|
||||
- Brokers privileged actions via local IPC:
|
||||
- Clawdis control socket (app-specific actions like notify/run)
|
||||
- PeekabooBridge socket (`bridge.sock`) for UI automation brokering (consumed by `peekaboo`; see `docs/mac/peekaboo.md`)
|
||||
- Provides a tiny CLI (`clawdis-mac`) that talks to the app; Node/TS shells out to it.
|
||||
- Replace the separate notifier helper pattern (Oracle) with a built-in notifier.
|
||||
- Offer a first-run experience similar to VibeTunnel’s onboarding (permissions + CLI install).
|
||||
- Runs (or connects to) the **Gateway** and exposes itself as a **node** so agents can reach macOS‑only features.
|
||||
- Hosts **PeekabooBridge** for UI automation (consumed by `peekaboo`; see `docs/mac/peekaboo.md`).
|
||||
- Installs a single CLI (`clawdis`) by symlinking the bundled binary.
|
||||
|
||||
## High-level design
|
||||
- SwiftPM package in `apps/macos/` (macOS 15+, Swift 6).
|
||||
- Targets:
|
||||
- `ClawdisIPC` (shared Codable types + helpers for app-specific commands).
|
||||
- `Clawdis` (LSUIElement MenuBarExtra app; hosts control socket + optional PeekabooBridgeHost).
|
||||
- `ClawdisCLI` (`clawdis-mac`; prints text by default, `--json` for scripts).
|
||||
- `ClawdisIPC` (shared Codable types + helpers for app‑internal actions).
|
||||
- `Clawdis` (LSUIElement MenuBarExtra app; hosts Gateway + node bridge + PeekabooBridgeHost).
|
||||
- Bundle ID: `com.steipete.clawdis`.
|
||||
- The CLI lives in the app bundle `Contents/Helpers/clawdis-mac`; dev symlink `bin/clawdis-mac` points there.
|
||||
- Node/TS layer calls the CLI; no direct privileged API calls from Node.
|
||||
- Bundled runtime binaries live under `Contents/Resources/Relay/`:
|
||||
- `clawdis-gateway` (bun‑compiled Gateway)
|
||||
- `clawdis` (bun‑compiled CLI)
|
||||
- The app symlinks `clawdis` into `/usr/local/bin` and `/opt/homebrew/bin`.
|
||||
|
||||
Note: `docs/mac/xpc.md` describes an aspirational long-term Mach/XPC architecture. The current direction for UI automation is PeekabooBridge (socket-based).
|
||||
## Gateway + node bridge
|
||||
- The mac app runs the Gateway in **local** mode (unless configured remote).
|
||||
- The mac app connects to the bridge as a **node** and advertises capabilities/commands.
|
||||
- Agent‑facing actions are exposed via `node.invoke` (no local control socket).
|
||||
|
||||
## IPC contract (ClawdisIPC)
|
||||
- Codable enums; small payloads (<1 MB enforced in listener):
|
||||
### Node commands (mac)
|
||||
- Canvas: `canvas.present|navigate|eval|snapshot|a2ui.*`
|
||||
- Camera: `camera.snap|camera.clip`
|
||||
- Screen: `screen.record`
|
||||
- System: `system.run` (shell) and `system.notify`
|
||||
|
||||
```
|
||||
enum Capability { notifications, accessibility, screenRecording, appleScript, microphone, speechRecognition }
|
||||
enum Request {
|
||||
notify(title, body, sound?)
|
||||
ensurePermissions([Capability], interactive: Bool)
|
||||
runShell(command:[String], cwd?, env?, timeoutSec?, needsScreenRecording: Bool)
|
||||
status
|
||||
}
|
||||
struct Response { ok: Bool; message?: String; payload?: Data }
|
||||
```
|
||||
- The control-socket server rejects oversize/unknown cases and validates the caller by code signature TeamID (with a `DEBUG`-only same-UID escape hatch controlled by `CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1`).
|
||||
### Permission advertising
|
||||
- Nodes include a `permissions` map in hello/pairing.
|
||||
- The Gateway surfaces it via `node.list` / `node.describe` so agents can decide what to run.
|
||||
|
||||
UI automation is not part of `ClawdisIPC.Request`:
|
||||
- UI automation is handled via the separate PeekabooBridge socket and is surfaced by the `peekaboo` CLI (see `docs/mac/peekaboo.md`).
|
||||
## CLI (`clawdis`)
|
||||
- The **only** CLI is `clawdis` (TS/bun). There is no `clawdis-mac` helper.
|
||||
- For mac‑specific actions, the CLI uses `node.invoke`:
|
||||
- `clawdis canvas present|navigate|eval|snapshot|a2ui push|a2ui reset`
|
||||
- `clawdis nodes run --node <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 Trimmy’s “Auto Trim” toggle. When paused, the broker returns `ok=false, message="clawdis paused"` for actions that would touch TCC. State is persisted (UserDefaults) and surfaced in menu and status view.
|
||||
- Onboarding (VibeTunnel-inspired): Welcome → What it does → Install CLI (shows `ln -s .../clawdis-mac /usr/local/bin`) → Permissions checklist with live status → Test notification → Done. Re-show when `welcomeVersion` bumps or CLI/app version mismatch.
|
||||
|
||||
## Built-in services
|
||||
- NotificationManager: UNUserNotificationCenter primary; AppleScript `display notification` fallback; respects the `--sound` value on each request.
|
||||
- PermissionManager: checks/requests Notifications, Accessibility (AX), Screen Recording (capture probe); publishes changes for UI.
|
||||
- UI automation + capture: provided by **PeekabooBridgeHost** when enabled (see `docs/mac/peekaboo.md`).
|
||||
- ShellExecutor: executes `Process` with timeout; rejects when `needsScreenRecording` and permission missing; returns stdout/stderr in payload.
|
||||
- ControlSocketServer actor: routes Request → managers; logs via OSLog.
|
||||
|
||||
## CLI (`clawdis-mac`)
|
||||
- Subcommands (text by default; `--json` for machine output; non-zero exit on failure):
|
||||
- `notify --title --body [--sound] [--priority passive|active|timeSensitive] [--delivery system|overlay|auto]`
|
||||
- `ensure-permissions --cap accessibility --cap screenRecording [--interactive]`
|
||||
- UI automation + capture: use `peekaboo …` (Clawdis hosts PeekabooBridge; see `docs/mac/peekaboo.md`)
|
||||
- `run -- cmd args... [--cwd] [--env KEY=VAL] [--timeout 30] [--needs-screen-recording]`
|
||||
- `status`
|
||||
- Nodes (bridge-connected companions):
|
||||
- `node list` — lists paired + currently connected nodes, including advertised capabilities (e.g. `canvas`, `camera`) and hardware identifiers (`deviceFamily`, `modelIdentifier`).
|
||||
- `node invoke --node <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 node‑side permission prompts, or always require explicit app UI action?
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -23,13 +23,11 @@ Run the Node-based Clawdis/clawdis gateway as a direct child of the LSUIElement
|
||||
- **TCC:** behaviorally, child processes often inherit the parent app’s “responsible process” for TCC, but this is *not a contract*. Continue to route all protected actions through the Swift app/broker so prompts stay tied to the signed app bundle.
|
||||
|
||||
## TCC guardrails (must keep)
|
||||
- Screen Recording, Accessibility, mic, and speech prompts must originate from the signed Swift app/broker. The Node child should never call these APIs directly; use the CLI broker (`clawdis-mac`) for:
|
||||
- `ensure-permissions`
|
||||
- `ui screenshot` (via PeekabooBridge host)
|
||||
- other `ui …` automation (see/click/type/scroll/wait) when implemented
|
||||
- mic/speech permission checks
|
||||
- notifications
|
||||
- shell runs that need `needs-screen-recording`
|
||||
- Screen Recording, Accessibility, mic, and speech prompts must originate from the signed Swift app/broker. The Node child should never call these APIs directly; route through the app’s node commands (via Gateway `node.invoke`) for:
|
||||
- `system.notify`
|
||||
- `system.run` (including `needsScreenRecording`)
|
||||
- `screen.record` / `camera.*`
|
||||
- PeekabooBridge UI automation (`peekaboo …`)
|
||||
- Usage strings (`NSMicrophoneUsageDescription`, `NSSpeechRecognitionUsageDescription`, etc.) stay in the app target’s Info.plist; a bare Node binary has none and would fail.
|
||||
- If you ever embed Node that *must* touch TCC, wrap that call in a tiny signed helper target inside the app bundle and have Node exec that helper instead of calling the API directly.
|
||||
|
||||
@@ -69,6 +67,6 @@ Run the Node-based Clawdis/clawdis gateway as a direct child of the LSUIElement
|
||||
- Do we want a tiny signed helper for rare TCC actions that cannot be brokered via the Swift app/broker?
|
||||
|
||||
## Decision snapshot (current recommendation)
|
||||
- Keep all TCC surfaces in the Swift app/broker (control socket + PeekabooBridgeHost).
|
||||
- Keep all TCC surfaces in the Swift app/broker (node commands + PeekabooBridgeHost).
|
||||
- Implement `GatewayProcessManager` with Swift Subprocess to start/stop the gateway on the “Clawdis Active” toggle.
|
||||
- Maintain the launchd path as a fallback for uptime/login persistence until child-mode proves stable.
|
||||
- Maintain the launchd path as a fallback for uptime/login persistence until child-mode proves stable.
|
||||
|
||||
@@ -67,7 +67,7 @@ What Clawdis should *not* embed:
|
||||
- **XPC**: don’t reintroduce helper targets; use the bridge.
|
||||
|
||||
## IPC / CLI surface
|
||||
### No `clawdis-mac ui …`
|
||||
### No `clawdis ui …`
|
||||
We avoid a parallel “Clawdis UI automation CLI”. Instead:
|
||||
- `peekaboo` is the user/agent-facing CLI surface for automation and capture.
|
||||
- Clawdis.app can host PeekabooBridge as a **thin TCC broker** so Peekaboo can piggyback on Clawdis permissions when Peekaboo.app isn’t running.
|
||||
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
Updated: 2025-12-08
|
||||
|
||||
This flow lets the macOS app act as a full remote control for a Clawdis gateway running on another host (e.g. a Mac Studio). All features—health checks, permissions bootstrapping via the helper CLI, Voice Wake forwarding, and Web Chat—reuse the same remote SSH configuration from *Settings → General*.
|
||||
This flow lets the macOS app act as a full remote control for a Clawdis gateway running on another host (e.g. a Mac Studio). All features—health checks, Voice Wake forwarding, and Web Chat—reuse the same remote SSH configuration from *Settings → General*.
|
||||
|
||||
## Modes
|
||||
- **Local (this Mac)**: Everything runs on the laptop. No SSH involved.
|
||||
@@ -15,7 +15,7 @@ This flow lets the macOS app act as a full remote control for a Clawdis gateway
|
||||
|
||||
## Prereqs on the remote host
|
||||
1) Install Node + pnpm and build/install the Clawdis CLI (`pnpm install && pnpm build && pnpm link --global`).
|
||||
2) Ensure `clawdis` is on PATH for non-interactive shells. If you prefer, symlink `clawdis-mac` too so TCC-capable actions can run remotely when needed.
|
||||
2) Ensure `clawdis` is on PATH for non-interactive shells (symlink into `/usr/local/bin` or `/opt/homebrew/bin` if needed).
|
||||
3) Open SSH with key auth. We recommend **Tailscale** IPs for stable reachability off-LAN.
|
||||
|
||||
## macOS app setup
|
||||
@@ -34,7 +34,7 @@ This flow lets the macOS app act as a full remote control for a Clawdis gateway
|
||||
|
||||
## Permissions
|
||||
- The remote host needs the same TCC approvals as local (Automation, Accessibility, Screen Recording, Microphone, Speech Recognition, Notifications). Run onboarding on that machine to grant them once.
|
||||
- When remote commands need local TCC (e.g., screenshots on the remote Mac), ensure `clawdis-mac` is installed there so the helper can request/hold those permissions.
|
||||
- Nodes advertise their permission state via `node.list` / `node.describe` so agents know what’s available.
|
||||
|
||||
## WhatsApp login flow (remote)
|
||||
- Run `clawdis login --verbose` **on the remote host**. Scan the QR with WhatsApp on your phone.
|
||||
@@ -47,10 +47,10 @@ This flow lets the macOS app act as a full remote control for a Clawdis gateway
|
||||
- **Voice Wake**: trigger phrases are forwarded automatically in remote mode; no separate forwarder is needed.
|
||||
|
||||
## Notification sounds
|
||||
Pick sounds per notification from scripts with the helper CLI, e.g.:
|
||||
Pick sounds per notification from scripts with `clawdis` and `node.invoke`, e.g.:
|
||||
|
||||
```bash
|
||||
clawdis-mac notify --title "Ping" --body "Remote gateway ready" --sound Glass
|
||||
clawdis nodes notify --node <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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Nodes: pairing, capabilities (canvas/camera), and the CLI helpers for screenshots + clips"
|
||||
summary: "Nodes: pairing, capabilities, permissions, and CLI helpers for canvas/camera/screen/system"
|
||||
read_when:
|
||||
- Pairing iOS/Android nodes to a gateway
|
||||
- Using node canvas/camera for agent context
|
||||
@@ -8,7 +8,7 @@ read_when:
|
||||
|
||||
# Nodes
|
||||
|
||||
A **node** is a companion device (iOS/Android today) that connects to the Gateway over the **Bridge** and exposes a small command surface (e.g. `canvas.*`, `camera.*`) via `node.invoke`.
|
||||
A **node** is a companion device (iOS/Android today) that connects to the Gateway over the **Bridge** and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`.
|
||||
|
||||
macOS can also run in **node mode**: the menubar app connects to the Gateway’s bridge and exposes its local canvas/camera commands as a node (so `clawdis nodes …` works against this Mac).
|
||||
|
||||
@@ -90,6 +90,25 @@ Notes:
|
||||
- Screen recordings are clamped to `<= 60s`.
|
||||
- `--no-audio` disables microphone capture (supported on iOS/Android; macOS uses system capture audio).
|
||||
|
||||
## System commands (mac node)
|
||||
|
||||
The macOS node exposes `system.run` and `system.notify`.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
clawdis nodes run --node <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).
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- Avoid double-sending actions when the bundled A2UI shell is present (let the shell forward clicks so it can resolve richer context).
|
||||
- Intercept `clawdis://…` navigations inside the Canvas WKWebView and route them through `DeepLinkHandler` (no NSWorkspace bounce).
|
||||
- `GatewayConnection` auto-starts the local gateway (and retries briefly) when a request fails in `.local` mode, so Canvas actions don’t silently fail if the gateway isn’t running yet.
|
||||
- Fix a crash that made `clawdis-mac canvas present`/`eval` look “hung”:
|
||||
- Fix a crash that made `clawdis canvas present`/`eval` look “hung”:
|
||||
- `VoicePushToTalkHotkey`’s NSEvent monitor could call `@MainActor` code off-main, triggering executor checks / EXC_BAD_ACCESS on macOS 26.2.
|
||||
- Now it hops back to the main actor before mutating state.
|
||||
- Preserve in-page state when closing Canvas (hide the window instead of closing the `WKWebView`).
|
||||
|
||||
64
docs/refactor/cli-unification.md
Normal file
64
docs/refactor/cli-unification.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
110
src/cli/canvas-cli.coverage.test.ts
Normal file
110
src/cli/canvas-cli.coverage.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
161
src/cli/nodes-cli.coverage.test.ts
Normal file
161
src/cli/nodes-cli.coverage.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("system-presence", () => {
|
||||
const instanceIdLower = instanceIdUpper.toLowerCase();
|
||||
|
||||
upsertPresence(instanceIdUpper, {
|
||||
host: "clawdis-mac",
|
||||
host: "clawdis",
|
||||
mode: "app",
|
||||
instanceId: instanceIdUpper,
|
||||
reason: "connect",
|
||||
|
||||
Reference in New Issue
Block a user